4 Commits

Author SHA1 Message Date
mjasin 94f6fe366d feat(recommendations): refactor handler to use clean IVectorSearchStore abstraction and fix unit tests 2026-06-06 11:28:14 +02:00
mjasin e9bb51af77 feat(ui): implement client-side [PAYWALL_TRIGGER] token parser, styling and tests 2026-06-06 11:07:21 +02:00
mjasin 93133a49b6 feat(intelligence): implement global hybrid search engine and monetization logic
- Created IUserLibraryStore and IVectorSearchStore abstractions to decouple relational DB and Qdrant gRPC logic from Application Layer
- Implemented MediatR GetGlobalIntelligenceQuery with value-first teaser RAG monetization logic
- Registered new request and response DTOs in AppJsonContext for Native AOT source-generated serialization
- Bound RagMonetizationOptions via IOptions pattern in appsettings.json configuration
- Added POST /api/intelligence endpoint on server and implemented GetGlobalIntelligenceAsync in WASM client service
- Refactored Intelligence.razor to consume the backend-driven global hybrid search Q&A engine
2026-06-06 10:55:58 +02:00
mjasin faf6ec826e feat(intelligence): implement Global AI Q&A screen and paywall blocker
- Implemented standard empty and active chat conversation states for the `/intelligence` page
- Created interactive `AiResponseRenderer` with AOT-compliant sentence splitting and payment gateway simulation
- Added scoped `LibraryStateService` to synchronize book ownership and updates across the application
- Obfuscated paywalled content in DOM to prevent inspection bypass
- Fixed local port connection mismatch by updating API configurations to use port 5104
2026-06-06 10:41:48 +02:00
112 changed files with 452 additions and 12031 deletions
-2
View File
@@ -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" />
-1
View File
@@ -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 .
+1 -6
View File
@@ -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`).
-1
View File
@@ -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
View File
@@ -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);
}
@@ -1,10 +0,0 @@
using FluentResults;
using NexusReader.Domain.Enums;
namespace NexusReader.Application.Abstractions.Services;
public interface IUserPreferenceStore
{
Task<Result> SaveThemePreferenceAsync(ThemeMode mode);
Task<Result<ThemeMode>> GetThemePreferenceAsync();
}
@@ -1,6 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Domain.Enums;
namespace NexusReader.Application.Commands.User;
public record UpdateThemeCommand(string UserId, ThemeMode Mode) : ICommand;
@@ -1,41 +0,0 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Commands.User;
public class UpdateThemeCommandHandler : ICommandHandler<UpdateThemeCommand>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public UpdateThemeCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result> Handle(UpdateThemeCommand request, CancellationToken cancellationToken)
{
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var user = await dbContext.Users
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
user.ThemePreference = request.Mode;
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to save theme preference in database.").CausedBy(ex));
}
}
}
@@ -16,23 +16,6 @@ namespace NexusReader.Application.Common;
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))] [JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))] [JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
[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.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,5 +0,0 @@
using NexusReader.Domain.Enums;
namespace NexusReader.Application.DTOs.User;
public record UpdateThemeRequest(ThemeMode Mode);
@@ -1,5 +1,4 @@
using NexusReader.Application.Constants; using NexusReader.Application.Constants;
using NexusReader.Domain.Enums;
namespace NexusReader.Application.DTOs.User; namespace NexusReader.Application.DTOs.User;
@@ -9,7 +8,6 @@ public record UserProfileDto
public string UserId { get; init; } = string.Empty; public string UserId { get; init; } = string.Empty;
public int AITokensUsed { get; init; } public int AITokensUsed { get; init; }
public Guid TenantId { get; init; } public Guid TenantId { get; init; }
public ThemeMode ThemePreference { get; init; } = ThemeMode.System;
/// <summary> /// <summary>
/// Relational data for the current subscription plan. /// Relational data for the current subscription plan.
@@ -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;
@@ -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));
}
}
@@ -27,7 +27,6 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
UserId = u.Id, UserId = u.Id,
AITokensUsed = u.AITokensUsed, AITokensUsed = u.AITokensUsed,
TenantIdString = u.TenantId, TenantIdString = u.TenantId,
ThemePreference = u.ThemePreference,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{ {
Id = u.SubscriptionPlan.Id, Id = u.SubscriptionPlan.Id,
@@ -107,7 +106,6 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
AverageQuizScore = averageQuizScore, AverageQuizScore = averageQuizScore,
DisplayName = userRaw.DisplayName, DisplayName = userRaw.DisplayName,
BooksReadCount = userRaw.BooksReadCount, BooksReadCount = userRaw.BooksReadCount,
ThemePreference = userRaw.ThemePreference,
ConceptsMappedCount = conceptsMappedCount, ConceptsMappedCount = conceptsMappedCount,
LastReadBook = userRaw.LastReadBook, LastReadBook = userRaw.LastReadBook,
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
@@ -1,711 +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("20260607104453_AddThemePreference")]
partial class AddThemePreference
{
/// <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.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.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.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,56 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Pgvector;
#nullable disable
namespace NexusReader.Data.Migrations
{
/// <inheritdoc />
public partial class AddThemePreference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Vector",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "Vector",
table: "KnowledgeUnits");
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AddColumn<int>(
name: "ThemePreference",
table: "AspNetUsers",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThemePreference",
table: "AspNetUsers");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AddColumn<Vector>(
name: "Vector",
table: "SemanticKnowledgeCache",
type: "vector(1536)",
nullable: true);
migrationBuilder.AddColumn<Vector>(
name: "Vector",
table: "KnowledgeUnits",
type: "vector(768)",
nullable: true);
}
}
}
@@ -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");
}
}
}
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence; using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable #nullable disable
@@ -20,6 +21,7 @@ namespace NexusReader.Data.Migrations
.HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@@ -172,103 +174,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")
@@ -359,6 +264,9 @@ namespace NexusReader.Data.Migrations
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<Vector>("Vector")
.HasColumnType("vector(768)");
b.Property<string>("Version") b.Property<string>("Version")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
@@ -480,11 +388,6 @@ namespace NexusReader.Data.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(128)");
b.Property<int>("ThemePreference")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<bool>("TwoFactorEnabled") b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -577,6 +480,9 @@ namespace NexusReader.Data.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(128)");
b.Property<Vector>("Vector")
.HasColumnType("vector(1536)");
b.HasKey("ContentHash"); b.HasKey("ContentHash");
b.HasIndex("ContentHash") b.HasIndex("ContentHash")
@@ -711,53 +617,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 +692,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");
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Domain.Enums;
namespace NexusReader.Data.Persistence; namespace NexusReader.Data.Persistence;
@@ -25,9 +24,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)
{ {
@@ -47,10 +43,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed) // Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
entity.Property(u => u.SubscriptionPlanId) entity.Property(u => u.SubscriptionPlanId)
.HasDefaultValue(1); .HasDefaultValue(1);
entity.Property(u => u.ThemePreference)
.HasConversion<int>()
.HasDefaultValue(ThemeMode.System);
}); });
modelBuilder.Entity<SubscriptionPlan>(entity => modelBuilder.Entity<SubscriptionPlan>(entity =>
@@ -117,48 +109,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)
{ {
-39
View File
@@ -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,7 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using NexusReader.Domain.Enums;
namespace NexusReader.Domain.Entities; namespace NexusReader.Domain.Entities;
@@ -66,9 +65,4 @@ public class NexusUser : IdentityUser
/// Last read timestamp. /// Last read timestamp.
/// </summary> /// </summary>
public DateTime? LastReadAt { get; set; } public DateTime? LastReadAt { get; set; }
/// <summary>
/// User's visual theme preference.
/// </summary>
public ThemeMode ThemePreference { get; set; } = ThemeMode.System;
} }
@@ -1,8 +0,0 @@
namespace NexusReader.Domain.Enums;
public enum ThemeMode
{
System = 0,
Dark = 1,
LightSepia = 2
}
@@ -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,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -95,21 +94,12 @@ internal sealed class VectorSearchStore : IVectorSearchStore
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken) private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("[VectorSearchStore] Attempted to generate embedding from empty text. Returning zero vector.");
return Array.Empty<float>();
}
var sw = Stopwatch.StartNew();
var response = await _retryPipeline.ExecuteAsync(async ct => var response = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync( await _embeddingGenerator.GenerateAsync(
new[] { text }, new[] { text },
new EmbeddingGenerationOptions { Dimensions = 768 }, new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken); cancellationToken: ct), cancellationToken);
sw.Stop();
_logger.LogDebug("[VectorSearchStore] Embedding generated in {ElapsedMs}ms for text of {Length} chars.", sw.ElapsedMilliseconds, text.Length);
return response.First().Vector.ToArray(); return response.First().Vector.ToArray();
} }
@@ -142,17 +132,10 @@ internal sealed class VectorSearchStore : IVectorSearchStore
private async Task<List<VectorChunk>> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken) private async Task<List<VectorChunk>> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken)
{ {
if (queryVector.Length == 0)
{
_logger.LogWarning("[VectorSearchStore] Empty query vector — skipping Qdrant search.");
return new List<VectorChunk>();
}
try try
{ {
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken); await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var sw = Stopwatch.StartNew();
var response = await _qdrantClient.SearchAsync( var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units", collectionName: "knowledge_units",
vector: queryVector, vector: queryVector,
@@ -160,8 +143,6 @@ internal sealed class VectorSearchStore : IVectorSearchStore
limit: (ulong)limit, limit: (ulong)limit,
cancellationToken: cancellationToken cancellationToken: cancellationToken
); );
sw.Stop();
_logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds);
return response.Select(point => return response.Select(point =>
{ {
@@ -188,7 +169,6 @@ internal sealed class VectorSearchStore : IVectorSearchStore
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken); var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
if (!exists) if (!exists)
{ {
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName);
await _qdrantClient.CreateCollectionAsync( await _qdrantClient.CreateCollectionAsync(
collectionName: collectionName, collectionName: collectionName,
vectorsConfig: new Qdrant.Client.Grpc.VectorParams vectorsConfig: new Qdrant.Client.Grpc.VectorParams
@@ -198,13 +178,11 @@ internal sealed class VectorSearchStore : IVectorSearchStore
}, },
cancellationToken: cancellationToken cancellationToken: cancellationToken
); );
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName);
} }
} }
catch (Exception ex) catch (Exception)
{ {
// Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate. // Ignore concurrent creation conflicts in multi-threaded/concurrent flows
_logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName);
} }
} }
} }
@@ -6,7 +6,6 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentResults; using FluentResults;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Queries.Recommendations; using NexusReader.Application.Queries.Recommendations;
@@ -14,31 +13,24 @@ namespace NexusReader.Infrastructure.Queries;
/// <summary> /// <summary>
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state, /// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
/// performing semantic search using <see cref="IVectorSearchStore"/> with book exclusion, and mapping upsells. /// performing semantic search using IVectorSearchStore with book exclusion, and mapping upsells.
/// </summary> /// </summary>
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>> public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
{ {
private readonly IUserReadingStateStore _readingStateStore; private readonly IUserReadingStateStore _readingStateStore;
private readonly IUserLibraryStore _libraryStore; private readonly IUserLibraryStore _libraryStore;
private readonly IVectorSearchStore _vectorSearchStore; private readonly IVectorSearchStore _vectorSearchStore;
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
/// <summary>
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
/// </summary>
public GetContextualRecommendationsQueryHandler( public GetContextualRecommendationsQueryHandler(
IUserReadingStateStore readingStateStore, IUserReadingStateStore readingStateStore,
IUserLibraryStore libraryStore, IUserLibraryStore libraryStore,
IVectorSearchStore vectorSearchStore, IVectorSearchStore vectorSearchStore)
ILogger<GetContextualRecommendationsQueryHandler> logger)
{ {
_readingStateStore = readingStateStore; _readingStateStore = readingStateStore;
_libraryStore = libraryStore; _libraryStore = libraryStore;
_vectorSearchStore = vectorSearchStore; _vectorSearchStore = vectorSearchStore;
_logger = logger;
} }
/// <inheritdoc />
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken) public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
{ {
if (string.IsNullOrEmpty(request.UserId)) if (string.IsNullOrEmpty(request.UserId))
@@ -52,7 +44,7 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken); var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken);
if (ebookId == null) if (ebookId == null)
{ {
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId); // Fallback: brand-new user with no reading history, return empty recommendations list safely
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>())); return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
} }
@@ -63,17 +55,14 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken); chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken);
} }
// Guard: empty chapter content cannot produce a meaningful embedding // Fallback: if no active chapter or content, try retrieving any chapter content from this book
if (string.IsNullOrWhiteSpace(chapterContent)) if (string.IsNullOrEmpty(chapterContent))
{ {
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>())); return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
} }
// Step 3: Perform similarity search using IVectorSearchStore // Step 3: Perform similarity search using IVectorSearchStore
var resolvedTenantId = tenantId ?? "global"; var resolvedTenantId = tenantId ?? "global";
_logger.LogDebug("[Recommendations] Performing vector search for user {UserId}, book {EbookId}, tenant {TenantId}.", request.UserId, ebookId, resolvedTenantId);
var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync( var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync(
chapterContent, chapterContent,
resolvedTenantId, resolvedTenantId,
@@ -114,10 +103,7 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
chapterTitle = labelProp.GetString() ?? chapterTitle; chapterTitle = labelProp.GetString() ?? chapterTitle;
} }
} }
catch (JsonException jsonEx) catch { }
{
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
}
} }
} }
@@ -133,12 +119,10 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
)); ));
} }
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
return Result.Ok(new ContextualRecommendationResponse(recommendations)); return Result.Ok(new ContextualRecommendationResponse(recommendations));
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[Recommendations] Downstream vector database or state query failed for user {UserId}.", request.UserId);
return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex)); return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex));
} }
} }
@@ -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}";
}
}
+2 -4
View File
@@ -8,7 +8,6 @@ using NexusReader.Application;
using MediatR; using MediatR;
using NexusReader.Maui.Infrastructure.Logging; using NexusReader.Maui.Infrastructure.Logging;
using NexusReader.Maui.Infrastructure.Identity; using NexusReader.Maui.Infrastructure.Identity;
using NexusReader.Maui.Services;
namespace NexusReader.Maui; namespace NexusReader.Maui;
@@ -45,7 +44,7 @@ public static class MauiProgram
// Minimal Infrastructure // Minimal Infrastructure
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>(); builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
builder.Services.AddSingleton<INativeStorageService, NexusReader.Infrastructure.Mobile.Services.MauiStorageService>(); builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
// Minimal Identity (Safe Mode) // Minimal Identity (Safe Mode)
builder.Services.AddScoped<NexusAuthenticationStateProvider>(); builder.Services.AddScoped<NexusAuthenticationStateProvider>();
@@ -68,8 +67,7 @@ public static class MauiProgram
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings(); var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings); builder.Services.AddSingleton(featureSettings);
builder.Services.AddSingleton<IUserPreferenceStore, MauiUserPreferenceStore>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddSingleton<IThemeService, ThemeService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>(); builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
@@ -1,61 +0,0 @@
using System.Net.Http;
using System.Net.Http.Json;
using FluentResults;
using NexusReader.Application.DTOs.User;
using NexusReader.Domain.Enums;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Maui.Services;
public class MauiUserPreferenceStore : IUserPreferenceStore
{
private readonly IHttpClientFactory _httpClientFactory;
public MauiUserPreferenceStore(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
{
try
{
var client = CreateClient();
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
if (response.IsSuccessStatusCode)
{
return Result.Ok();
}
var error = await response.Content.ReadAsStringAsync();
return Result.Fail($"Failed to save cloud theme preference on mobile: {error}");
}
catch (Exception ex)
{
return Result.Fail(new Error("Network error saving mobile theme preference to cloud.").CausedBy(ex));
}
}
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
{
try
{
var client = CreateClient();
var response = await client.GetAsync("identity/profile");
if (response.IsSuccessStatusCode)
{
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
return profile != null
? Result.Ok(profile.ThemePreference)
: Result.Fail("Failed to deserialize mobile profile response.");
}
return Result.Fail($"Failed to fetch theme preference from cloud on mobile: {response.ReasonPhrase}");
}
catch (Exception ex)
{
return Result.Fail(new Error("Network error retrieving theme preference on mobile.").CausedBy(ex));
}
}
}
+3 -18
View File
@@ -9,29 +9,14 @@
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<script> <script>
(function () { (function () {
try { const savedTheme = localStorage.getItem('theme');
var themeMode = localStorage.getItem('theme-mode'); const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var savedTheme = localStorage.getItem('theme'); const isLight = savedTheme === 'light' || (!savedTheme && !systemPrefersDark);
var isLight = false;
if (themeMode === '2' || savedTheme === 'light') {
isLight = true;
} else if (themeMode === '1' || savedTheme === 'dark') {
isLight = false;
} else {
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
}
if (isLight) { if (isLight) {
document.documentElement.classList.add('theme-light'); document.documentElement.classList.add('theme-light');
document.documentElement.classList.remove('theme-dark');
} else { } else {
document.documentElement.classList.add('theme-dark');
document.documentElement.classList.remove('theme-light'); document.documentElement.classList.remove('theme-light');
} }
} catch (e) {
// Fail silently
}
})(); })();
</script> </script>
</head> </head>
@@ -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,9 +1,7 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.Application.DTOs.AI @using NexusReader.Application.DTOs.AI
@using Microsoft.Extensions.Logging
@inject IQuizStateService QuizState @inject IQuizStateService QuizState
@inject KnowledgeCoordinator Coordinator @inject KnowledgeCoordinator Coordinator
@inject ILogger<AiAssistantBubble> Logger
@implements IDisposable @implements IDisposable
<div class="ai-bubble-container"> <div class="ai-bubble-container">
@@ -136,7 +134,7 @@
catch (Exception ex) catch (Exception ex)
{ {
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue; _displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
Logger.LogError(ex, "[AiAssistantBubble] Error fetching summary for block {BlockId}.", ContextBlockId); Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
} }
finally finally
{ {
@@ -2,12 +2,10 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.Application.DTOs.AI @using NexusReader.Application.DTOs.AI
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using Microsoft.Extensions.Logging
@using System.Net.Http.Json @using System.Net.Http.Json
@inject HttpClient Http @inject HttpClient Http
@inject ILibraryStateService LibraryStateService @inject ILibraryStateService LibraryStateService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject ILogger<AiResponseRenderer> Logger
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")"> <div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
<div class="message-avatar" aria-hidden="true"> <div class="message-avatar" aria-hidden="true">
@@ -202,12 +200,12 @@
} }
else else
{ {
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId); Console.WriteLine("[AiResponseRenderer] Purchase failed on server.");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId); Console.WriteLine($"[AiResponseRenderer] Error processing purchase: {ex.Message}");
} }
finally finally
{ {
@@ -30,17 +30,17 @@
} }
.user-row .message-avatar { .user-row .message-avatar {
background: var(--bg-surface); background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%);
color: var(--text-main); color: #ffffff;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 10px var(--border); box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
} }
.ai-row .message-avatar { .ai-row .message-avatar {
background: linear-gradient(135deg, #005f38 0%, #004024 100%); background: linear-gradient(135deg, #005f38 0%, #004024 100%);
color: #e6fffa; color: #e6fffa;
border: 1px solid var(--accent); border: 1px solid rgba(0, 255, 153, 0.4);
box-shadow: 0 0 10px var(--accent-glow); box-shadow: 0 0 10px rgba(0, 255, 153, 0.25);
} }
.message-bubble { .message-bubble {
@@ -55,23 +55,23 @@
} }
.user-bubble { .user-bubble {
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
color: var(--text-main); color: #e4e4e7;
border-top-right-radius: 4px; border-top-right-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
} }
.ai-bubble { .ai-bubble {
background: var(--bg-surface); background: rgba(26, 26, 30, 0.6);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
color: var(--text-main); color: #e2e8f0;
border-top-left-radius: 4px; border-top-left-radius: 4px;
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.03); box-shadow: 0 4px 25px rgba(0, 0, 0, 0.2);
} }
.paywalled-bubble { .paywalled-bubble {
border-color: var(--accent-glow); border-color: rgba(16, 185, 129, 0.15);
} }
.message-header { .message-header {
@@ -109,8 +109,8 @@
.paywall-teaser { .paywall-teaser {
position: relative; position: relative;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%); mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
filter: blur(2px); filter: blur(2px);
pointer-events: none; pointer-events: none;
-webkit-user-select: none; -webkit-user-select: none;
@@ -119,12 +119,12 @@
/* Upsell Card */ /* Upsell Card */
.upsell-card { .upsell-card {
background: var(--bg-base); background: #1a1a1e;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--accent-glow); border: 1px solid rgba(16, 185, 129, 0.25);
padding: 1.5rem; padding: 1.5rem;
margin-top: 1rem; margin-top: 1rem;
box-shadow: 0 8px 32px var(--accent-glow), 0 4px 12px var(--border); box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.4);
animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
} }
@@ -141,14 +141,14 @@
.upsell-header h4 { .upsell-header h4 {
margin: 0; margin: 0;
color: var(--accent); color: #10b981;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.upsell-text { .upsell-text {
color: var(--text-main); color: rgba(255, 255, 255, 0.75);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.55; line-height: 1.55;
margin: 0 0 1.25rem 0; margin: 0 0 1.25rem 0;
@@ -177,16 +177,15 @@
} }
.btn-primary { .btn-primary {
background: var(--accent); background: #10b981;
border: none; border: none;
color: var(--bg-surface); color: #121214;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--accent); background: #0d9668;
opacity: 0.9;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 15px var(--accent-glow); box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
} }
.btn-primary:active:not(:disabled) { .btn-primary:active:not(:disabled) {
@@ -194,19 +193,19 @@
} }
.btn-primary:disabled { .btn-primary:disabled {
background: var(--accent-glow); background: rgba(16, 185, 129, 0.5);
color: var(--text-muted); color: rgba(18, 18, 20, 0.6);
cursor: not-allowed; cursor: not-allowed;
} }
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
border: 1px solid var(--accent); border: 1px solid #10b981;
color: var(--accent); color: #10b981;
} }
.btn-secondary:hover { .btn-secondary:hover {
background: var(--accent-glow); background: rgba(16, 185, 129, 0.05);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -219,9 +218,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
background: var(--accent-glow); background: rgba(16, 185, 129, 0.1);
border: 1px solid var(--accent); border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--accent); color: #10b981;
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: 8px;
margin-top: 1.25rem; margin-top: 1.25rem;
@@ -239,8 +238,8 @@
.payment-spinner { .payment-spinner {
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid var(--border); border: 2px solid rgba(18, 18, 20, 0.2);
border-top-color: var(--accent); border-top-color: #121214;
border-radius: 50%; border-radius: 50%;
margin-right: 0.75rem; margin-right: 0.75rem;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
@@ -266,49 +265,3 @@
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
/* ============================================================
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
============================================================ */
.theme-light .ai-row .message-avatar {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #ffffff;
border: 1px solid rgba(16, 185, 129, 0.2);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
}
.theme-light .user-row .message-avatar {
box-shadow: 0 2px 8px rgba(139, 130, 115, 0.1);
}
.theme-light .upsell-card {
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04);
}
.theme-light .btn-primary {
background: var(--accent);
color: #ffffff;
}
.theme-light .btn-primary:hover:not(:disabled) {
background: #059669;
color: #ffffff;
opacity: 1;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15);
}
.theme-light .btn-secondary {
border-color: var(--accent);
color: var(--accent);
}
.theme-light .btn-secondary:hover {
background: rgba(16, 185, 129, 0.05);
}
.theme-light .paywall-teaser {
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
}
@@ -1,13 +1,10 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using Microsoft.Extensions.Logging
@using System.Linq
@inject IFocusModeService FocusMode @inject IFocusModeService FocusMode
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IThemeService ThemeService @inject IThemeService ThemeService
@inject IKnowledgeService KnowledgeService @inject IKnowledgeService KnowledgeService
@inject ILogger<IntelligenceToolbar> Logger
@implements IDisposable @implements IDisposable
<aside class="intelligence-toolbar"> <aside class="intelligence-toolbar">
@@ -49,30 +46,26 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
FocusMode.OnFocusModeChanged += HandleUpdate; FocusMode.OnFocusModeChanged += HandleUpdate;
ThemeService.OnThemeChanged += HandleThemeChanged; ThemeService.OnThemeChanged += HandleThemeChangedAsync;
} }
private async Task HandleClearCache() private async Task HandleClearCache()
{ {
Logger.LogInformation("[IntelligenceToolbar] Requesting cache clear..."); Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
var result = await KnowledgeService.ClearCacheAsync(); var result = await KnowledgeService.ClearCacheAsync();
if (result.IsSuccess) if (result.IsSuccess)
{ {
Logger.LogInformation("[IntelligenceToolbar] Cache cleared successfully."); Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
}
else
{
Logger.LogWarning("[IntelligenceToolbar] Cache clear failed: {Errors}", string.Join("; ", result.Errors.Select(e => e.Message)));
} }
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged); private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged); private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
FocusMode.OnFocusModeChanged -= HandleUpdate; FocusMode.OnFocusModeChanged -= HandleUpdate;
ThemeService.OnThemeChanged -= HandleThemeChanged; ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
} }
} }
@@ -1,12 +1,10 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models @using NexusReader.UI.Shared.Models
@using NexusReader.Application.DTOs.AI @using NexusReader.Application.DTOs.AI
@using Microsoft.Extensions.Logging
@inject KnowledgeCoordinator Coordinator @inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@inject IQuizStateService QuizService @inject IQuizStateService QuizService
@inject IJSRuntime JS @inject IJSRuntime JS
@inject ILogger<SelectionAiPanel> Logger
@if (IsVisible) @if (IsVisible)
{ {
@@ -66,7 +64,7 @@
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top); Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}");
if (Coordinates != _lastCoordinates) if (Coordinates != _lastCoordinates)
{ {
@@ -102,7 +100,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar."); Console.WriteLine($"[SelectionAiPanel] Error positioning toolbar: {ex.Message}");
} }
} }
} }
@@ -135,7 +133,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId); Console.WriteLine($"[SelectionAiPanel] Error requesting summary: {ex.Message}");
} }
finally finally
{ {
@@ -175,7 +173,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId); Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}");
} }
finally finally
{ {
@@ -233,126 +233,3 @@
.lock-icon { .lock-icon {
color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.2);
} }
/* ============================================================
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
============================================================ */
.theme-light .concepts-map::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
.theme-light .empty-map-state {
background: rgba(0, 0, 0, 0.01);
border-color: var(--border);
color: var(--text-muted);
}
.theme-light .empty-map-state .dim-icon {
color: var(--text-muted);
opacity: 0.4;
}
.theme-light .timeline-step:hover {
background: rgba(0, 0, 0, 0.02);
}
.theme-light .timeline-step.unlocked:hover {
border-color: rgba(16, 185, 129, 0.15);
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.05);
}
.theme-light .timeline-step.selected {
background: rgba(16, 185, 129, 0.04);
border-color: var(--accent);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.15);
}
.theme-light .node-circle {
background: var(--bg-surface);
}
.theme-light .unlocked .node-circle {
background: var(--bg-surface);
border-color: var(--accent);
color: var(--accent);
box-shadow: none;
}
.theme-light .locked .node-circle {
background: var(--bg-base);
border-color: var(--border);
color: var(--text-muted);
}
.theme-light .node-glow {
display: none;
}
.theme-light .track-active {
background: var(--accent);
box-shadow: none;
}
.theme-light .track-inactive {
background: var(--border);
}
.theme-light .node-content {
background: var(--bg-surface);
border: 1px solid var(--border);
}
.theme-light .timeline-step.selected .node-content {
background: var(--bg-surface);
border-color: rgba(16, 185, 129, 0.2);
}
.theme-light .segment-tag {
color: var(--text-muted);
}
.theme-light .unlocked .segment-tag {
color: var(--accent);
}
.theme-light .badge-unlocked {
background: rgba(16, 185, 129, 0.08);
color: var(--accent);
border-color: rgba(16, 185, 129, 0.2);
}
.theme-light .badge-locked {
background: var(--bg-base);
color: var(--text-muted);
border-color: var(--border);
}
.theme-light .node-title {
color: var(--text-main);
}
.theme-light .timeline-step.unlocked:hover .node-title {
color: var(--accent);
}
.theme-light .locked .node-title {
color: var(--text-muted);
}
.theme-light .node-desc {
color: var(--text-muted);
}
.theme-light .locked .node-desc {
color: var(--text-muted);
}
.theme-light .check-icon {
color: var(--accent);
}
.theme-light .lock-icon {
color: var(--text-muted);
}
@@ -1,112 +0,0 @@
@using NexusReader.UI.Shared.Components.Atoms
@using Microsoft.Extensions.Logging
@inject IRecommendationService RecommendationService
@inject NavigationManager NavigationManager
@inject ILogger<ContextualRecommendationsWidget> Logger
<section class="recommendations-panel glass-panel" aria-label="Kontekstowe rekomendacje">
<div class="panel-header">
<div class="header-left">
<NexusIcon Name="sparkles" Size="18" />
<h4>Odkryj Więcej</h4>
</div>
<span class="panel-badge">AI</span>
</div>
@if (_isLoading)
{
<div @key='"loading"' class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
<div class="spinner-ring">
<div class="spinner-track"></div>
<div class="spinner-head"></div>
</div>
<span class="loading-label">Analizowanie kontekstu lektury…</span>
</div>
}
else if (_hasError)
{
<div @key='"error"' class="empty-state">
<NexusIcon Name="alert-circle" Size="32" />
<p>Nie udało się załadować rekomendacji.</p>
</div>
}
else if (_recommendations is null || _recommendations.Count == 0)
{
<div @key='"empty"' class="empty-state">
<NexusIcon Name="book-open" Size="32" />
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
</div>
}
else
{
<ul @key='"list"' class="recommendations-list" role="list">
@foreach (var rec in _recommendations)
{
<li @key="rec.TargetBookId" class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
role="listitem">
<div class="rec-content">
<div class="rec-meta">
<span class="match-badge" title="Dopasowanie semantyczne @rec.MatchPercentage%">
@rec.MatchPercentage<span class="match-unit">%</span>
</span>
@if (rec.IsPremiumUpsell)
{
<span class="upsell-tag" aria-label="Książka premium">Premium</span>
}
</div>
<p class="rec-book-title">@rec.BookTitle</p>
<p class="rec-chapter-title">@rec.ChapterTitle</p>
</div>
<button class="rec-action-btn"
@onclick="() => HandleRecommendationClick(rec)"
aria-label="@(rec.IsPremiumUpsell ? "Kup " + rec.BookTitle : "Przejdź do " + rec.BookTitle)">
<NexusIcon Name="@(rec.IsPremiumUpsell ? "shopping-cart" : "arrow-right")" Size="16" />
</button>
</li>
}
</ul>
}
</section>
@code {
private List<RecommendationDto>? _recommendations;
private bool _isLoading = true;
private bool _hasError;
protected override async Task OnInitializedAsync()
{
await LoadRecommendationsAsync();
}
private async Task LoadRecommendationsAsync()
{
_isLoading = true;
_hasError = false;
try
{
_recommendations = await RecommendationService.GetRecommendationsAsync();
if (_recommendations is null)
{
_hasError = true;
Logger.LogWarning("[ContextualRecommendationsWidget] RecommendationService returned null; displaying error state.");
}
}
finally
{
_isLoading = false;
}
}
private void HandleRecommendationClick(RecommendationDto rec)
{
if (rec.IsPremiumUpsell)
{
NavigationManager.NavigateTo($"/catalog?highlight={rec.TargetBookId}");
}
else
{
NavigationManager.NavigateTo($"/reader/{rec.TargetBookId}");
}
}
}
@@ -1,331 +0,0 @@
/* ContextualRecommendationsWidget.razor.css
Uses Nexus Design System tokens (--nexus-*) for consistency.
*/
.recommendations-panel {
width: 100%;
padding: 1.75rem;
margin-top: 2.5rem;
background: var(--nexus-surface, #1a1a1e);
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
border-radius: 12px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.recommendations-panel:hover {
border-color: rgba(16, 185, 129, 0.2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* ── Panel Header ── */
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--nexus-accent, #10b981);
}
.header-left h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--nexus-text-primary, #ffffff);
letter-spacing: 0.02em;
}
.panel-badge {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.2rem 0.55rem;
border-radius: 100px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(59, 130, 246, 0.1));
border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--nexus-accent, #10b981);
text-transform: uppercase;
}
/* ── Loading State ── */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem 1rem;
}
.spinner-ring {
position: relative;
width: 40px;
height: 40px;
}
.spinner-track {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.05);
}
.spinner-head {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: var(--nexus-accent, #10b981);
animation: nexus-spin 0.8s linear infinite;
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
}
@keyframes nexus-spin {
to { transform: rotate(360deg); }
}
.loading-label {
font-size: 0.82rem;
color: var(--nexus-text-secondary, #a1a1aa);
font-style: italic;
}
/* ── Empty / Error State ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
text-align: center;
color: var(--nexus-text-secondary, #a1a1aa);
opacity: 0.65;
}
.empty-state p {
margin: 0;
font-size: 0.875rem;
}
/* ── Recommendations List ── */
.recommendations-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.recommendation-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.1rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: default;
}
.recommendation-item:hover {
background: rgba(255, 255, 255, 0.04);
transform: translateX(2px);
}
.recommendation-item.premium {
border-color: rgba(245, 158, 11, 0.2);
}
.recommendation-item.premium:hover {
border-color: rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.04);
}
.recommendation-item.owned {
border-color: rgba(16, 185, 129, 0.1);
}
.recommendation-item.owned:hover {
border-color: rgba(16, 185, 129, 0.25);
}
/* ── Rec Content ── */
.rec-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rec-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.15rem;
}
.match-badge {
font-size: 0.8rem;
font-weight: 700;
color: var(--nexus-accent, #10b981);
background: rgba(16, 185, 129, 0.1);
border-radius: 4px;
padding: 0.1rem 0.45rem;
}
.match-unit {
font-size: 0.65rem;
font-weight: 500;
}
.upsell-tag {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 4px;
padding: 0.1rem 0.45rem;
text-transform: uppercase;
}
.rec-book-title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--nexus-text-primary, #ffffff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rec-chapter-title {
margin: 0;
font-size: 0.78rem;
color: var(--nexus-text-secondary, #a1a1aa);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Action Button ── */
.rec-action-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
color: var(--nexus-text-secondary, #a1a1aa);
cursor: pointer;
transition: all 0.2s ease;
}
.rec-action-btn:hover {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
color: var(--nexus-accent, #10b981);
transform: scale(1.1);
}
.premium .rec-action-btn:hover {
background: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
@media (max-width: 768px) {
.recommendations-panel {
padding: 1.25rem;
}
}
/* ============================================================
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
============================================================ */
.theme-light .recommendations-panel {
background: var(--bg-surface);
border: 1px solid var(--border);
}
.theme-light .recommendations-panel:hover {
box-shadow: 0 8px 24px rgba(139, 130, 115, 0.12);
}
.theme-light .header-left h4 {
color: var(--text-main);
}
.theme-light .spinner-track {
border: 3px solid rgba(0, 0, 0, 0.05);
}
.theme-light .loading-label,
.theme-light .empty-state {
color: var(--text-muted);
}
.theme-light .recommendation-item {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.theme-light .recommendation-item:hover {
background: rgba(0, 0, 0, 0.04);
}
.theme-light .recommendation-item.premium {
border-color: rgba(245, 158, 11, 0.2);
}
.theme-light .recommendation-item.premium:hover {
border-color: rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.04);
}
.theme-light .recommendation-item.owned {
border-color: rgba(16, 185, 129, 0.1);
}
.theme-light .recommendation-item.owned:hover {
border-color: rgba(16, 185, 129, 0.25);
}
.theme-light .rec-book-title {
color: var(--text-main);
}
.theme-light .rec-chapter-title {
color: var(--text-muted);
}
.theme-light .rec-action-btn {
border: 1px solid rgba(0, 0, 0, 0.08);
color: var(--text-muted);
}
.theme-light .rec-action-btn:hover {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
color: var(--accent);
}
.theme-light .premium .rec-action-btn:hover {
background: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
@@ -5,14 +5,14 @@
<section class="current-reading-card glass-panel"> <section class="current-reading-card glass-panel">
@if (Book != null) @if (Book != null)
{ {
<div @key='"current-reading-book"' class="card-layout"> <div 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="@(Book.CoverUrl ?? "https://via.placeholder.com/120x180?text=No+Cover")" 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,8 +41,8 @@
} }
<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 Continue Reading
<NexusIcon Name="arrow-right" Size="16" /> <NexusIcon Name="arrow-right" Size="16" />
</button> </button>
</div> </div>
@@ -51,7 +51,7 @@
} }
else else
{ {
<div @key='"current-reading-empty"' class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<NexusIcon Name="book-open" Size="48" /> <NexusIcon Name="book-open" Size="48" />
</div> </div>
@@ -194,155 +194,19 @@
margin: 0; margin: 0;
} }
@media (max-width: 767px) { @media (max-width: 768px) {
.current-reading-card {
background: #1e1e22; /* Lighter anthracite slate for depth */
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); /* Ambient card shadow */
padding: 14px;
border-radius: 16px;
border: 1px solid var(--border);
}
.card-layout { .card-layout {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: center;
text-align: left; text-align: center;
gap: 0.75rem; gap: 1.5rem;
} }
.book-cover { .book-title, .chapter-name {
align-self: center;
width: 90px;
}
.book-details {
width: 100%;
text-align: left;
}
.book-title {
font-size: 1.25rem;
text-align: left;
white-space: normal; white-space: normal;
} }
.author-name {
text-align: left;
font-size: 0.8rem;
}
.header-info, .chapter-progress { .header-info, .chapter-progress {
align-items: stretch; align-items: center;
}
.chapter-name {
white-space: normal;
font-size: 0.8rem;
}
.chapter-progress {
margin: 0.5rem 0; /* Margin separator before tracking bar */
width: 100%;
}
.progress-bar-container {
width: 100%;
}
.book-excerpt {
display: -webkit-box; /* Normal display to wrap synopsis */
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 0.75rem;
text-align: left;
font-size: 0.85rem;
}
.actions {
width: 100%;
margin-top: 0.25rem;
}
.actions .btn-nexus.outline {
width: 100%;
justify-content: center;
padding: 0.75rem 1.25rem; /* Larger touch target */
box-sizing: border-box;
background: transparent !important;
color: var(--accent) !important;
border: 1px solid var(--accent) !important;
display: inline-flex !important;
align-items: center !important;
font-weight: 600 !important;
}
.actions .btn-nexus.outline:hover {
background: var(--accent-glow) !important;
color: var(--accent) !important;
}
.theme-light .current-reading-card {
background: #ffffff; /* Pure white card surface for light theme */
box-shadow: 0 12px 24px rgba(139, 130, 115, 0.12);
} }
} }
/* ============================================================
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
============================================================ */
.theme-light .current-reading-card {
background: var(--bg-surface);
border: 1px solid var(--border);
}
.theme-light .current-reading-card:hover {
background: var(--bg-surface);
border-color: var(--accent);
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.12);
}
.theme-light .book-cover img {
box-shadow: 0 15px 35px rgba(139, 130, 115, 0.18);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.theme-light .book-title {
color: var(--text-main);
}
.theme-light .author-name {
color: var(--text-muted);
}
.theme-light .chapter-name {
color: var(--text-main);
}
.theme-light .progress-bar-container {
background: #e4e1d9;
}
.theme-light .progress-bar-fill {
box-shadow: 0 0 6px rgba(16, 185, 129, 0.2);
}
.theme-light .book-excerpt {
color: var(--text-muted);
}
.theme-light .empty-text h3 {
color: var(--text-main);
}
.theme-light .empty-text p {
color: var(--text-muted);
}
.theme-light .empty-icon {
color: var(--accent);
filter: none;
}
@@ -114,7 +114,7 @@
ThemeService.OnThemeChanged += HandleThemeChanged; ThemeService.OnThemeChanged += HandleThemeChanged;
} }
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged); private Task HandleThemeChanged() => InvokeAsync(StateHasChanged);
private double GetDashOffset() private double GetDashOffset()
{ {
@@ -127,7 +127,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await Coordinator.ClearAsync(); await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += HandleThemeChanged; ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged; NavigationService.OnNavigationChanged += OnNavigationChanged;
QuizService.OnQuizUpdated += HandleUpdate; QuizService.OnQuizUpdated += HandleUpdate;
@@ -451,8 +451,6 @@
private Task HandleUpdate() => InvokeAsync(StateHasChanged); private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
private void HandleEscape() private void HandleEscape()
{ {
if (ViewModel != null) if (ViewModel != null)
@@ -468,7 +466,7 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
ThemeService.OnThemeChanged -= HandleThemeChanged; ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged; NavigationService.OnNavigationChanged -= OnNavigationChanged;
QuizService.OnQuizUpdated -= HandleUpdate; QuizService.OnQuizUpdated -= HandleUpdate;
@@ -1,5 +1,4 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable
@using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@@ -7,13 +6,13 @@
@if (!_isFullyLoaded) @if (!_isFullyLoaded)
{ {
<div @key='"preloader"' class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000; color: #ffffff;"> <div class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000; color: #ffffff;">
<div class="preloader-spinner"></div> <div class="preloader-spinner"></div>
<div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div> <div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div>
</div> </div>
} }
<div @key='"hub-container"' class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "") @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")"> <div class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<!-- Mobile Sticky Top-bar --> <!-- Mobile Sticky Top-bar -->
@@ -95,12 +94,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">
@@ -117,32 +110,6 @@
</button> </button>
</div> </div>
</aside> </aside>
<!-- Reader Mobile Dock v3 -->
<nav class="reader-mobile-dock">
<NavLink class="dock-item" href="/dashboard" Match="NavLinkMatch.All" title="Pulpit">
<NexusIcon Name="home" Size="20" />
<span class="dock-text">Pulpit</span>
</NavLink>
<NavLink class="dock-item" href="/catalog" title="Katalog">
<NexusIcon Name="layout" Size="20" />
<span class="dock-text">Katalog</span>
</NavLink>
<NavLink class="dock-item central-action" href="/intelligence" title="Globalne AI">
<div class="central-action-inner">
<NexusIcon Name="robot" Size="20" />
</div>
<span class="dock-text">AI</span>
</NavLink>
<NavLink class="dock-item" href="/my-books" title="Moje">
<NexusIcon Name="book-open" Size="20" />
<span class="dock-text">Moje</span>
</NavLink>
<NavLink class="dock-item" href="/profile" title="Konto">
<NexusIcon Name="user" Size="20" />
<span class="dock-text">Konto</span>
</NavLink>
</nav>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -157,7 +124,6 @@
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!; [Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private IIdentityService IdentityService { get; set; } = default!; [Inject] private IIdentityService IdentityService { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private IThemeService ThemeService { get; set; } = default!;
private bool _isSyncing = false; private bool _isSyncing = false;
private bool _isMobileMenuOpen = false; private bool _isMobileMenuOpen = false;
@@ -165,8 +131,6 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
ThemeService.OnThemeChanged += HandleThemeChanged;
if (_isSyncing) return; if (_isSyncing) return;
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
@@ -178,16 +142,10 @@
} }
} }
private void HandleThemeChanged(ThemeMode mode) protected override void OnAfterRender(bool firstRender)
{
InvokeAsync(StateHasChanged);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender)
{ {
await ThemeService.InitializeAsync();
_isFullyLoaded = true; _isFullyLoaded = true;
StateHasChanged(); StateHasChanged();
} }
@@ -209,10 +167,4 @@
await IdentityService.LogoutAsync(); await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/account/logout-form", true); NavigationManager.NavigateTo("/account/logout-form", true);
} }
public void Dispose()
{
ThemeService.OnThemeChanged -= HandleThemeChanged;
GC.SuppressFinalize(this);
}
} }
@@ -2,16 +2,16 @@
display: flex; display: flex;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: var(--bg-base); background: #121214;
color: var(--text-main); color: #e4e4e7;
overflow: hidden; overflow: hidden;
} }
::deep .hub-sidebar { ::deep .hub-sidebar {
width: 80px; width: 80px;
height: 100%; height: 100%;
background: var(--bg-surface); background: #0d0d0d;
border-right: 1px solid var(--border); border-right: 1px solid rgba(255, 255, 255, 0.05);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: 100; z-index: 100;
@@ -55,7 +55,7 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 54px; height: 54px;
color: var(--text-muted); color: #8b8273;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease, background-color 0.2s ease; transition: color 0.2s ease, background-color 0.2s ease;
position: relative; position: relative;
@@ -63,7 +63,7 @@
::deep .nav-item:hover { ::deep .nav-item:hover {
color: #10b981; color: #10b981;
background: rgba(0, 0, 0, 0.04); background: rgba(255, 255, 255, 0.01);
} }
::deep .nav-item:focus-visible { ::deep .nav-item:focus-visible {
@@ -103,7 +103,7 @@
::deep .sidebar-footer { ::deep .sidebar-footer {
padding: 1.5rem 0; padding: 1.5rem 0;
border-top: 1px solid var(--border); border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -119,15 +119,15 @@
::deep .user-avatar { ::deep .user-avatar {
width: 36px; width: 36px;
height: 36px; height: 36px;
background: var(--bg-base); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--text-main); color: #e4e4e7;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -138,7 +138,7 @@
::deep .logout-btn { ::deep .logout-btn {
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-muted); color: #8b8273;
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
border-radius: 8px; border-radius: 8px;
@@ -157,7 +157,7 @@
flex: 1; flex: 1;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
background: var(--bg-base); background: #121214;
} }
.hub-content { .hub-content {
@@ -194,11 +194,7 @@
display: none; display: none;
} }
.reader-mobile-dock { @media (max-width: 768px) {
display: none;
}
@media (max-width: 767px) {
.nexus-mobile-topbar { .nexus-mobile-topbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -208,10 +204,10 @@
left: 0; left: 0;
right: 0; right: 0;
height: 60px; height: 60px;
background: var(--bg-surface); background: rgba(18, 18, 18, 0.85);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 0 1.25rem; padding: 0 1.25rem;
z-index: 150; z-index: 150;
} }
@@ -266,7 +262,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;
@@ -299,7 +295,7 @@
bottom: 0; bottom: 0;
width: 280px; width: 280px;
height: 100%; height: 100%;
background: var(--bg-surface); background: #141414;
z-index: 200; z-index: 200;
transform: translateX(-100%); transform: translateX(-100%);
will-change: transform; will-change: transform;
@@ -328,7 +324,7 @@
::deep .sidebar-header { ::deep .sidebar-header {
padding: 1.5rem 1.25rem; padding: 1.5rem 1.25rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
::deep .sidebar-nav { ::deep .sidebar-nav {
@@ -346,217 +342,4 @@
} }
} }
/* ============================================================
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
Scoped via .theme-light on an ancestor element.
============================================================ */
/* --- Desktop Sidebar: warm paper shadow --- */
.theme-light ::deep .hub-sidebar {
box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08);
background: var(--bg-surface) !important;
border-right: 1px solid var(--border) !important;
}
/* --- Logo icon: remove neon glow --- */
.theme-light ::deep .logo-icon {
filter: none;
}
/* --- Nav item hover: ensure green text, warm hover bg --- */
.theme-light ::deep .nav-item:hover {
color: #10b981;
background: rgba(0, 0, 0, 0.02);
}
/* --- Nav active indicator: reduced glow --- */
.theme-light ::deep .nav-item.active::before {
box-shadow: 0 0 8px rgba(16, 185, 129, 0.3);
}
/* --- Nexus loader: remove neon drop-shadow --- */
.theme-light ::deep .nexus-loader {
filter: none;
}
/* --- Mobile Styles --- */
@media (max-width: 768px) {
/* Hamburger button: dark text on warm paper */
.theme-light .hamburger-btn {
color: #292524;
}
.theme-light .hamburger-btn:hover {
background: rgba(0, 0, 0, 0.04);
}
/* User avatar mini: solid accent, white text, no neon glow */
.theme-light .user-avatar-mini {
background: #10b981;
color: #ffffff;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
}
/* Pulsing logo: subtle accent pulse, no neon glow */
.theme-light .pulsing-logo {
animation: pulse-glow-light 2s infinite ease-in-out;
}
@keyframes pulse-glow-light {
0%, 100% {
filter: none;
opacity: 0.85;
}
50% {
filter: drop-shadow(0 0 4px rgba(16, 185, 129, 0.2));
opacity: 1;
}
}
/* Mobile sidebar open state: warm shadow instead of dark */
.theme-light .mobile-menu-open ::deep .hub-sidebar {
box-shadow: 10px 0 30px rgba(139, 130, 115, 0.2);
}
/* Content padding for bottom navigation dock */
.hub-content {
padding: 1.25rem 1.25rem calc(1.25rem + 96px + env(safe-area-inset-bottom, 0px)) !important;
}
/* Reader Mobile Dock v3 */
::deep .reader-mobile-dock {
display: flex;
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
left: 20px;
right: 20px;
height: 64px;
background: rgba(26, 26, 30, 0.75); /* Translucent dark mode */
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border); /* Microscopic perimeter border */
border-radius: 30px; /* Floating capsule rounded borders */
z-index: 150;
padding: 0 16px;
justify-content: space-around;
align-items: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
::deep .dock-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s ease, filter 0.2s ease;
position: relative;
gap: 2px;
}
::deep .dock-item:hover, ::deep .dock-item.active {
color: var(--accent) !important;
filter: drop-shadow(0 0 4px var(--accent-glow)); /* Clean accent glow drop-shadow */
}
::deep .dock-item ::deep svg,
::deep .dock-item ::deep .nexus-icon,
::deep .dock-item svg,
::deep .dock-item .nexus-icon {
color: inherit !important;
fill: currentColor !important;
}
::deep .dock-text {
display: block !important;
font-size: 10px;
font-weight: 500;
margin-top: 2px;
white-space: nowrap;
color: inherit;
}
/* Central action button style */
::deep .dock-item.central-action {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
flex: 1;
height: 100%;
gap: 2px;
margin-top: 0;
transform: none !important;
z-index: 160;
}
::deep .central-action-inner {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent); /* Solid green background */
color: #ffffff !important; /* White robot icon */
display: flex;
align-items: center;
justify-content: center;
border: none !important;
box-shadow: 0 4px 10px rgba(0, 255, 153, 0.3);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s ease;
}
::deep .central-action-inner ::deep svg,
::deep .central-action-inner ::deep .nexus-icon,
::deep .central-action-inner svg,
::deep .central-action-inner .nexus-icon {
color: #ffffff !important;
fill: currentColor !important;
}
::deep .dock-item.central-action:hover .central-action-inner,
::deep .dock-item.central-action.active .central-action-inner {
transform: scale(1.05);
box-shadow: 0 4px 14px rgba(0, 255, 153, 0.5);
}
::deep .central-action-glow {
display: none; /* Purged background glow */
}
/* Light Theme Overrides */
.theme-light ::deep .reader-mobile-dock {
background: rgba(244, 241, 234, 0.9); /* Translucent light mode warm paper background */
box-shadow: 0 8px 30px rgba(139, 130, 115, 0.15);
}
.theme-light ::deep .dock-item {
color: var(--text-muted);
}
.theme-light ::deep .dock-item:hover, .theme-light ::deep .dock-item.active {
color: var(--accent) !important;
filter: drop-shadow(0 0 3px var(--accent-glow));
}
.theme-light ::deep .central-action-inner {
background: var(--accent) !important;
color: #ffffff !important;
border: none !important;
box-shadow: 0 4px 10px rgba(16, 185, 129, 0.25) !important;
}
.theme-light ::deep .dock-item.central-action:hover .central-action-inner,
.theme-light ::deep .dock-item.central-action.active .central-action-inner {
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.35) !important;
transform: scale(1.05);
}
.theme-light ::deep .central-action-glow {
display: none;
}
}
@@ -343,7 +343,7 @@
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync; InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged; InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync; GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
ThemeService.OnThemeChanged += HandleThemeChanged; ThemeService.OnThemeChanged += HandleThemeChangedAsync;
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate; Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
@@ -359,7 +359,7 @@
} }
} }
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged); private async Task HandleThemeChangedAsync() => await InvokeAsync(StateHasChanged);
private void SetActiveTab(SidebarTab tab) private void SetActiveTab(SidebarTab tab)
{ {
@@ -520,7 +520,7 @@
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync; InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged; InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync; GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
ThemeService.OnThemeChanged -= HandleThemeChanged; ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate; Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
try try
@@ -33,7 +33,7 @@
<span>lub</span> <span>lub</span>
</div> </div>
<EditForm FormName="login-form" Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form"> <EditForm Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="field-group"> <div class="field-group">
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<form @formname="hidden-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none"> <form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
<input type="hidden" name="email" value="@_loginModel.Email" /> <input type="hidden" name="email" value="@_loginModel.Email" />
<input type="hidden" name="password" value="@_loginModel.Password" /> <input type="hidden" name="password" value="@_loginModel.Password" />
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" /> <input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
@@ -117,10 +117,7 @@
[SupplyParameterFromQuery(Name = "returnUrl")] [SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; } public string? ReturnUrl { get; set; }
#pragma warning disable BL0008 private LoginModel _loginModel = new();
[SupplyParameterFromForm(FormName = "login-form")]
private LoginModel _loginModel { get; set; } = new();
#pragma warning restore BL0008
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
private bool _showPassword; private bool _showPassword;
@@ -4,10 +4,8 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@attribute [Authorize] @attribute [Authorize]
@using NexusReader.Domain.Enums
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IThemeService ThemeService
<div class="profile-page-container"> <div class="profile-page-container">
<div class="background-radial"></div> <div class="background-radial"></div>
@@ -99,31 +97,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Theme Preferences Card -->
<div class="metric-card glass-panel full-width theme-preference-card">
<div class="card-header">
<NexusIcon Name="settings" Size="24" Color="#10b981" />
<h3>Preferencje Wizualne</h3>
</div>
<div class="card-body theme-selector-layout">
<p class="theme-description">Wybierz profil wizualny systemu zoptymalizowany dla Twojego urządzenia i warunków czytania.</p>
<div class="theme-options">
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.System ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.System)">
<NexusIcon Name="cpu" Size="16" />
<span>Systemowy</span>
</button>
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.Dark ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.Dark)">
<NexusIcon Name="moon" Size="16" />
<span>Modern Deep Dark</span>
</button>
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.LightSepia ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.LightSepia)">
<NexusIcon Name="sun" Size="16" />
<span>Warm Sepia</span>
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
} }
@@ -137,7 +110,6 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await ThemeService.InitializeAsync();
var result = await IdentityService.GetProfileAsync(); var result = await IdentityService.GetProfileAsync();
if (result.IsSuccess) if (result.IsSuccess)
{ {
@@ -146,12 +118,6 @@
StateHasChanged(); StateHasChanged();
} }
private async Task ChangeTheme(ThemeMode mode)
{
await ThemeService.SetThemeAsync(mode);
StateHasChanged();
}
private int CalculateProgress() private int CalculateProgress()
{ {
if (_profile == null || _profile.AITokenLimit == 0) return 0; if (_profile == null || _profile.AITokenLimit == 0) return 0;
@@ -2,8 +2,8 @@
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background-color: var(--bg-base); background-color: #121214;
color: var(--text-main); color: #e4e4e7;
overflow-x: hidden; overflow-x: hidden;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -26,7 +26,7 @@
.mesh-overlay { .mesh-overlay {
position: absolute; position: absolute;
top: 0; left: 0; width: 100%; height: 100%; top: 0; left: 0; width: 100%; height: 100%;
background-image: radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0); background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.02) 1px, transparent 0);
background-size: 32px 32px; background-size: 32px 32px;
z-index: 1; z-index: 1;
} }
@@ -63,7 +63,7 @@
.avatar-inner { .avatar-inner {
width: 120px; width: 120px;
height: 120px; height: 120px;
background: var(--bg-surface); background: #1a1a1e;
border: 2px solid #10b981; border: 2px solid #10b981;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
@@ -98,7 +98,7 @@
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--text-main); color: #ffffff;
} }
.system-rank { .system-rank {
@@ -120,17 +120,17 @@
.glass-panel { .glass-panel {
padding: 32px; padding: 32px;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px; border-radius: 12px;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.glass-panel:hover { .glass-panel:hover {
border-color: var(--accent); border-color: rgba(16, 185, 129, 0.2);
transform: translateY(-4px); transform: translateY(-4px);
background: var(--bg-surface); background: #1e1e24;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
} }
.metric-card { .metric-card {
@@ -154,7 +154,7 @@
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--text-muted); color: #a0aec0;
margin: 0; margin: 0;
} }
@@ -177,14 +177,14 @@
gap: 8px; gap: 8px;
} }
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: var(--text-main); line-height: 1; } .usage-values .current { font-size: 2.5rem; font-weight: 800; color: #fff; line-height: 1; }
.usage-values .separator { font-size: 1.2rem; color: var(--border); } .usage-values .separator { font-size: 1.2rem; color: #4a5568; }
.usage-values .total { font-size: 1.2rem; color: var(--text-muted); font-weight: 600; } .usage-values .total { font-size: 1.2rem; color: #718096; font-weight: 600; }
.usage-progress { .usage-progress {
width: 100%; width: 100%;
height: 6px; height: 6px;
background: var(--border); background: rgba(255, 255, 255, 0.05);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
} }
@@ -198,7 +198,7 @@
.metric-label { .metric-label {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: #718096;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -218,7 +218,7 @@
.score-label { .score-label {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: #718096;
text-transform: uppercase; text-transform: uppercase;
margin-top: 4px; margin-top: 4px;
} }
@@ -229,11 +229,11 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px 16px; padding: 10px 16px;
background: var(--bg-base); background: rgba(16, 185, 129, 0.05);
border: 1px solid var(--border); border: 1px solid rgba(16, 185, 129, 0.1);
border-radius: 12px; border-radius: 12px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-main); color: #cbd5e0;
} }
.truncate { .truncate {
@@ -273,9 +273,9 @@
} }
.plan-badge.free { .plan-badge.free {
background: var(--bg-base); background: rgba(255, 255, 255, 0.05);
color: var(--text-muted); color: #a1a1aa;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.1);
} }
.tenant-tag { .tenant-tag {
@@ -348,111 +348,3 @@
.btn-nexus { width: 100%; justify-content: center; } .btn-nexus { width: 100%; justify-content: center; }
.username { font-size: 2.2rem; } .username { font-size: 2.2rem; }
} }
/* Theme Preference Card Styles */
.theme-preference-card {
margin-top: 12px;
}
.theme-description {
font-size: 0.9rem;
color: var(--text-muted);
margin: 0 0 16px 0;
}
.theme-options {
display: flex;
gap: 16px;
width: 100%;
}
.theme-option-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 20px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.theme-option-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: var(--text-main);
}
.theme-option-btn.active {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border-color: #10b981;
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
}
@media (max-width: 768px) {
.theme-options {
flex-direction: column;
}
}
/* ============================================
Light Theme Overrides — Warm Paper / Soft Sepia
============================================ */
/* Background radial — warmer, slightly stronger glow */
.theme-light .background-radial {
background: radial-gradient(circle, rgba(16, 185, 129, 0.04) 0%, transparent 70%);
}
/* Avatar — keep green accent, reduce glow intensity */
.theme-light .avatar-inner {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.12), inset 0 0 15px rgba(16, 185, 129, 0.05);
}
/* Avatar glow ring — softer border */
.theme-light .avatar-glow {
border-color: rgba(16, 185, 129, 0.2);
}
/* Glass panel hover — warm sepia shadow instead of pure black */
.theme-light .glass-panel:hover {
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.12);
}
/* Progress bar — reduce neon glow */
.theme-light .progress-bar {
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
}
/* Decorative text — dark ink on light bg instead of light on dark */
.theme-light .decoration {
color: rgba(0, 0, 0, 0.04);
}
/* Tenant tag — warm stone gray */
.theme-light .tenant-tag {
color: #78716c;
}
/* Loader — disable neon drop-shadow, softer border */
.theme-light .nexus-loader {
border-color: rgba(16, 185, 129, 0.15);
border-top-color: #10b981;
filter: none;
}
/* Theme option active — reduce glow in light mode */
.theme-light .theme-option-btn.active {
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
}
/* Progress bar track — light stone gray */
.theme-light .usage-progress {
background: #e4e1d9;
}
@@ -21,7 +21,7 @@
<p class="auth-subtitle">Utwórz nowe konto</p> <p class="auth-subtitle">Utwórz nowe konto</p>
</div> </div>
<EditForm FormName="register-form" Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form"> <EditForm Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="field-group"> <div class="field-group">
@@ -71,17 +71,14 @@
</div> </div>
</div> </div>
<form @formname="hidden-register-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none"> <form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
<input type="hidden" name="email" value="@_registerModel.Email" /> <input type="hidden" name="email" value="@_registerModel.Email" />
<input type="hidden" name="password" value="@_registerModel.Password" /> <input type="hidden" name="password" value="@_registerModel.Password" />
<input type="hidden" name="rememberMe" value="false" /> <input type="hidden" name="rememberMe" value="false" />
</form> </form>
@code { @code {
#pragma warning disable BL0008 private RegisterModel _registerModel = new();
[SupplyParameterFromForm(FormName = "register-form")]
private RegisterModel _registerModel { get; set; } = new();
#pragma warning restore BL0008
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
@@ -4,13 +4,11 @@
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using Microsoft.Extensions.Logging
@using System.Net.Http.Json @using System.Net.Http.Json
@inject HttpClient Http @inject HttpClient Http
@inject IReaderNavigationService ReaderNavigation @inject IReaderNavigationService ReaderNavigation
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject ILibraryStateService LibraryStateService @inject ILibraryStateService LibraryStateService
@inject ILogger<Catalog> Logger
<div class="catalog-page"> <div class="catalog-page">
<header class="catalog-header"> <header class="catalog-header">
@@ -223,7 +221,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[Catalog] Failed to load books."); Console.WriteLine($"[Catalog] Failed to load books: {ex.Message}");
if (OperatingSystem.IsBrowser()) if (OperatingSystem.IsBrowser())
{ {
_isLoading = false; _isLoading = false;
@@ -14,13 +14,13 @@
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--text-main); color: #ffffff;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.catalog-header .subtitle { .catalog-header .subtitle {
font-size: 1rem; font-size: 1rem;
color: var(--text-muted); color: #a1a1aa;
margin: 0; margin: 0;
} }
@@ -38,27 +38,27 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
border-radius: 12px; border-radius: 12px;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
} }
.course-card:hover { .course-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
border-color: var(--accent); border-color: rgba(16, 185, 129, 0.2);
} }
.card-cover-container { .card-cover-container {
position: relative; position: relative;
height: 200px; height: 200px;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.2);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.card-cover { .card-cover {
@@ -147,9 +147,8 @@
align-self: flex-start; align-self: flex-start;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
color: var(--text-muted); color: #a1a1aa;
background: var(--bg-base); background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
border-radius: 4px; border-radius: 4px;
text-transform: uppercase; text-transform: uppercase;
@@ -176,21 +175,21 @@
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
margin: 0 0 0.4rem 0; margin: 0 0 0.4rem 0;
color: var(--text-main); color: #ffffff;
line-height: 1.3; line-height: 1.3;
font-family: var(--nexus-font-sans, "Outfit", sans-serif); font-family: var(--nexus-font-sans, "Outfit", sans-serif);
} }
.course-author { .course-author {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: #a1a1aa;
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
} }
.course-desc { .course-desc {
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.5; line-height: 1.5;
color: var(--text-muted); color: #a1a1aa;
margin: 0 0 1.5rem 0; margin: 0 0 1.5rem 0;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
@@ -205,8 +204,8 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: #a1a1aa;
border-top: 1px solid var(--border); border-top: 1px solid rgba(255, 255, 255, 0.05);
padding-top: 0.75rem; padding-top: 0.75rem;
} }
@@ -257,14 +256,14 @@
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
height: 440px; height: 440px;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
opacity: 0.6; opacity: 0.6;
} }
.skeleton-cover { .skeleton-cover {
height: 200px; height: 200px;
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%); background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: loading 1.5s infinite; animation: loading 1.5s infinite;
} }
@@ -277,7 +276,7 @@
} }
.skeleton-line { .skeleton-line {
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%); background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: loading 1.5s infinite; animation: loading 1.5s infinite;
border-radius: 4px; border-radius: 4px;
@@ -315,9 +314,9 @@
gap: 1.25rem; gap: 1.25rem;
padding: 1.25rem 2.25rem; padding: 1.25rem 2.25rem;
border-radius: 40px; border-radius: 40px;
background: var(--bg-surface); background: rgba(13, 13, 15, 0.85);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.08);
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@@ -333,7 +332,7 @@
.loader-text { .loader-text {
font-weight: 500; font-weight: 500;
color: var(--text-main); color: #ffffff;
font-size: 0.95rem; font-size: 0.95rem;
} }
@@ -357,54 +356,3 @@
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; } from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
to { transform: translate(-50%, -50%) scale(1); opacity: 1; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
} }
/* ============================================
LIGHT THEME OVERRIDES — Warm Paper / Soft Sepia
============================================ */
.theme-light .course-card:hover {
box-shadow: 0 12px 30px rgba(139, 130, 115, 0.15);
}
.theme-light .card-cover-container {
background: rgba(0, 0, 0, 0.03);
}
.theme-light .cover-overlay {
background: rgba(0, 0, 0, 0.5);
}
.theme-light .course-card:hover .start-action {
color: #292524;
}
.theme-light .dotnet-gradient,
.theme-light .blazor-gradient,
.theme-light .graph-gradient {
background: #e4e1d9;
}
.theme-light .cover-code-text {
color: var(--text-main);
text-shadow: none;
}
@media (max-width: 767px) {
.catalog-page {
padding: 1.5rem 1rem calc(1.5rem + 72px + env(safe-area-inset-bottom, 0px)) !important;
}
.catalog-header {
margin-bottom: 1.5rem;
}
.catalog-header h1 {
font-size: 1.75rem;
}
.catalog-grid, .loading-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
}
@@ -17,11 +17,11 @@
align-items: center; align-items: center;
gap: 2rem; gap: 2rem;
padding: 1.25rem 2rem; padding: 1.25rem 2rem;
background: var(--bg-surface); background: rgba(20, 20, 20, 0.35);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px; border-radius: 16px;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05); box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
} }
.header-back .btn-back { .header-back .btn-back {
@@ -30,22 +30,22 @@
} }
.header-back .btn-back:hover { .header-back .btn-back:hover {
border-color: var(--accent); border-color: var(--nexus-neon);
color: var(--accent); color: var(--nexus-neon);
background: var(--accent-glow); background: var(--nexus-primary-glow);
box-shadow: 0 0 10px var(--accent-glow); box-shadow: 0 0 10px var(--nexus-primary-glow);
} }
.header-title h1 { .header-title h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: #fff;
} }
.header-title .subtitle { .header-title .subtitle {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: rgba(255, 255, 255, 0.4);
} }
.header-actions .btn-action { .header-actions .btn-action {
@@ -56,7 +56,7 @@
} }
.header-actions .btn-action:hover { .header-actions .btn-action:hover {
box-shadow: 0 0 20px var(--accent-glow); box-shadow: 0 0 20px var(--nexus-primary-glow);
} }
/* Grid Layout */ /* Grid Layout */
@@ -73,26 +73,28 @@
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl, 16px);
} }
.pane-header { .pane-header {
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.pane-header h3 { .pane-header h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-main); color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.pane-content {
flex-grow: 1;
overflow: hidden;
}
/* Loading, Error and Empty States */ /* Loading, Error and Empty States */
.loading-state, .error-state, .empty-dashboard-state { .loading-state, .error-state, .empty-dashboard-state {
display: flex; display: flex;
@@ -116,15 +118,15 @@
} }
.neon-pulse { .neon-pulse {
color: var(--accent); color: var(--nexus-neon);
filter: drop-shadow(0 0 10px var(--accent-glow)); filter: drop-shadow(0 0 10px var(--nexus-neon));
animation: robot-pulse 2s infinite ease-in-out; animation: robot-pulse 2s infinite ease-in-out;
} }
@keyframes robot-pulse { @keyframes robot-pulse {
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--accent-glow)); } 0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
50% { transform: scale(1.08); filter: drop-shadow(0 0 25px var(--accent-glow)); } 50% { transform: scale(1.08); filter: drop-shadow(0 0 25px var(--nexus-neon)); }
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--accent-glow)); } 100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
} }
.scan-line { .scan-line {
@@ -133,8 +135,8 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 2px; height: 2px;
background: var(--accent); background: var(--nexus-neon);
box-shadow: 0 0 15px var(--accent); box-shadow: 0 0 15px var(--nexus-neon);
animation: scan 2s infinite linear; animation: scan 2s infinite linear;
opacity: 0.8; opacity: 0.8;
} }
@@ -147,7 +149,7 @@
.loading-text { .loading-text {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-muted); color: rgba(255, 255, 255, 0.7);
margin-top: 1rem; margin-top: 1rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -162,18 +164,17 @@
} }
.dim-icon { .dim-icon {
color: var(--text-muted); color: rgba(255, 255, 255, 0.15);
opacity: 0.4;
} }
.empty-dashboard-state h2, .error-state h3 { .empty-dashboard-state h2, .error-state h3 {
color: var(--text-main); color: #fff;
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
font-weight: 600; font-weight: 600;
} }
.empty-dashboard-state p, .error-state p { .empty-dashboard-state p, .error-state p {
color: var(--text-muted); color: rgba(255, 255, 255, 0.45);
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.5; line-height: 1.5;
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
@@ -188,25 +189,25 @@
flex-grow: 1; flex-grow: 1;
padding: 3rem; padding: 3rem;
text-align: center; text-align: center;
color: var(--text-muted); color: rgba(255, 255, 255, 0.4);
} }
.empty-glowing-brain { .empty-glowing-brain {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 50%;
background: var(--accent-glow); background: rgba(0, 255, 153, 0.04);
border: 1px solid var(--accent); border: 1px solid rgba(0, 255, 153, 0.15);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: 0 0 20px var(--accent-glow); box-shadow: 0 0 20px var(--nexus-primary-glow);
} }
.workspace-empty h4 { .workspace-empty h4 {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
color: var(--text-main); color: #fff;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
} }
@@ -226,7 +227,7 @@
.workspace-header { .workspace-header {
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.node-meta { .node-meta {
@@ -241,7 +242,7 @@
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--accent); color: var(--nexus-neon);
} }
.badge { .badge {
@@ -255,22 +256,22 @@
} }
.badge-unlocked { .badge-unlocked {
background: var(--accent-glow); background: rgba(0, 255, 153, 0.08);
color: var(--accent); color: var(--nexus-neon);
border: 1px solid var(--accent); border: 1px solid rgba(0, 255, 153, 0.2);
} }
.badge-locked { .badge-locked {
background: var(--bg-base); background: rgba(255, 255, 255, 0.05);
color: var(--text-muted); color: rgba(255, 255, 255, 0.4);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
} }
.workspace-title { .workspace-title {
margin: 0; margin: 0;
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: #fff;
} }
.workspace-body { .workspace-body {
@@ -290,22 +291,22 @@
background: transparent; background: transparent;
} }
.workspace-body::-webkit-scrollbar-thumb { .workspace-body::-webkit-scrollbar-thumb {
background: var(--border); background: rgba(255, 255, 255, 0.08);
border-radius: 3px; border-radius: 3px;
} }
.workspace-body::-webkit-scrollbar-thumb:hover { .workspace-body::-webkit-scrollbar-thumb:hover {
background: var(--accent); background: var(--nexus-neon);
} }
.locked-warning { .locked-warning {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
background: rgba(217, 119, 6, 0.05); background: rgba(255, 171, 0, 0.04);
border: 1px solid rgba(217, 119, 6, 0.15); border: 1px solid rgba(255, 171, 0, 0.15);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
color: var(--text-main); color: rgba(255, 255, 255, 0.85);
} }
.lock-warning-icon { .lock-warning-icon {
@@ -325,14 +326,14 @@
margin: 0; margin: 0;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.4; line-height: 1.4;
color: var(--text-muted); color: rgba(255, 255, 255, 0.55);
} }
.metadata-section h4 { .metadata-section h4 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: #aaa;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
@@ -344,12 +345,12 @@
margin: 0; margin: 0;
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.6; line-height: 1.6;
color: var(--text-main); color: rgba(255, 255, 255, 0.7);
} }
.summary-box { .summary-box {
background: var(--bg-base); background: rgba(255, 255, 255, 0.02);
border-left: 3px solid var(--accent); border-left: 3px solid var(--nexus-neon);
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
padding: 1rem; padding: 1rem;
margin-top: 0.25rem; margin-top: 0.25rem;
@@ -364,9 +365,9 @@
.term-pill { .term-pill {
font-size: 0.75rem; font-size: 0.75rem;
background: var(--bg-base); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
color: var(--text-muted); color: rgba(255, 255, 255, 0.6);
padding: 0.3rem 0.75rem; padding: 0.3rem 0.75rem;
border-radius: 20px; border-radius: 20px;
font-weight: 500; font-weight: 500;
@@ -374,14 +375,14 @@
} }
.term-pill:hover { .term-pill:hover {
border-color: var(--accent); border-color: rgba(0, 255, 153, 0.2);
color: var(--accent); color: var(--nexus-neon);
background: var(--accent-glow); background: rgba(0, 255, 153, 0.03);
} }
.workspace-footer { .workspace-footer {
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
border-top: 1px solid var(--border); border-top: 1px solid rgba(255, 255, 255, 0.05);
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -402,68 +403,3 @@
justify-content: center; justify-content: center;
} }
} }
/* ============================================
Light Theme Overrides — Warm Paper / Soft Sepia
============================================ */
/* Dashboard header — warm sepia shadow instead of pure black */
.theme-light .dashboard-header {
box-shadow: 0 4px 30px rgba(139, 130, 115, 0.05);
}
/* Neon pulse icon — disable glow filter entirely */
.theme-light .neon-pulse {
filter: none;
}
/* Override the neon pulse keyframe states in light mode */
.theme-light .neon-pulse {
animation-name: robot-pulse-light;
}
@keyframes robot-pulse-light {
0% { transform: scale(1); filter: none; }
50% { transform: scale(1.08); filter: none; }
100% { transform: scale(1); filter: none; }
}
/* Scan line — reduce glow intensity */
.theme-light .scan-line {
box-shadow: 0 0 8px rgba(16, 185, 129, 0.3);
opacity: 0.5;
}
/* Glowing brain empty state — subtle warm glow */
.theme-light .empty-glowing-brain {
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
}
/* Error icon — reduce drop-shadow intensity */
.theme-light .error-icon {
filter: drop-shadow(0 0 4px rgba(255, 74, 74, 0.2));
}
/* Back button hover — warm glow instead of neon */
.theme-light .header-back .btn-back:hover {
box-shadow: 0 0 8px rgba(16, 185, 129, 0.1);
}
/* Action button hover — warm glow */
.theme-light .header-actions .btn-action:hover {
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
}
@media (max-width: 767px) {
.concepts-dashboard-container {
padding: 1rem 0.75rem calc(1.5rem + 72px + env(safe-area-inset-bottom, 0px)) !important;
gap: 1rem;
}
.dashboard-header {
padding: 1rem;
gap: 0.75rem;
}
.header-title h1 {
font-size: 1.25rem;
}
}
@@ -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">&times;</button>
</div>
<div class="modal-body">
<p>Zamrażasz obecny szkic książki <strong>@_activePublishBookTitle</strong> jako nową wersję publiczną.</p>
<div class="form-group">
<label for="versionInput">Sygnatura Wersji (np. v1.0.0)</label>
<input id="versionInput" type="text" class="form-control" @bind="_customVersionString" @bind:event="oninput" placeholder="Wpisz tag wersji..." />
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">@_errorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn-nexus secondary" @onclick="ClosePublishModal">Anuluj</button>
<button type="button" class="btn-nexus primary glow-btn" @onclick="SubmitPublishVersionAsync" disabled="@_isSubmitting">
@(_isSubmitting ? "Wysyłanie..." : "Zatwierdź wersję")
</button>
</div>
</div>
</div>
}
<!-- Manage Revisions Modal -->
@if (_isRevisionsModalOpen && _activeRevisionsBookId.HasValue)
{
<div class="modal-backdrop" @onclick="CloseRevisionsModal">
<div class="modal-content glass-panel" @onclick:stopPropagation>
<div class="modal-header">
<h3>Rejestr Rewizji: @_activeRevisionsBookTitle</h3>
<button class="close-btn" @onclick="CloseRevisionsModal">&times;</button>
</div>
<div class="modal-body">
@if (_revisionsLoading)
{
<div class="spinner-container">
<div class="spinner-glow small"></div>
<span>Wczytywanie historii...</span>
</div>
}
else if (_revisionsList == null || !_revisionsList.Any())
{
<p class="empty-revisions">Brak zarejestrowanych rewizji.</p>
}
else
{
<div class="revisions-list">
@foreach (var revision in _revisionsList)
{
<div class="revision-item">
<div class="revision-header">
<span class="revision-tag @(revision.IsPublished ? "published" : "draft")">
@(revision.IsPublished ? revision.VersionString : "Szkic roboczy")
</span>
<span class="revision-date">
@(revision.PublishedAt.HasValue ? revision.PublishedAt.Value.ToString("g") : revision.CreatedAt.ToString("g"))
</span>
</div>
<div class="revision-meta">
<span>Utworzono: @revision.CreatedAt.ToString("yyyy-MM-dd HH:mm")</span>
</div>
</div>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn-nexus secondary" @onclick="CloseRevisionsModal">Zamknij</button>
</div>
</div>
</div>
}
<!-- Create Book Modal -->
@if (_isCreateBookModalOpen)
{
<div class="modal-backdrop" @onclick="CloseCreateBookModal">
<div class="modal-content glass-panel" @onclick:stopPropagation>
<div class="modal-header">
<h2>Nowa Publikacja</h2>
<button class="close-btn" @onclick="CloseCreateBookModal">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<EditForm Model="_createBookModel" OnValidSubmit="SubmitCreateBookAsync">
<DataAnnotationsValidator />
<div class="modal-body">
<div class="creator-layout">
<!-- Book Cover Preview -->
<div class="cover-preview creator-cover">
<div class="cover-mockup-design">
<div class="cover-accent-line"></div>
<div class="cover-main-content">
<span class="cover-title-text">
@(string.IsNullOrWhiteSpace(_createBookModel.Title) ? "Tytuł Książki" : _createBookModel.Title)
</span>
<span class="cover-author-text">
Szkic roboczy
</span>
</div>
<div class="cover-logo-container">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
<span class="cover-brand">Nexus</span>
</div>
</div>
</div>
<!-- Form inputs -->
<div class="creator-form-inputs">
<div class="form-group">
<label for="newBookTitle">Tytuł Książki</label>
<InputText id="newBookTitle" class="form-input" @bind-Value="_createBookModel.Title" placeholder="Wpisz tytuł książki..." />
<ValidationMessage For="@(() => _createBookModel.Title)" class="validation-message" />
</div>
<div class="form-group">
<label for="newBookDescription">Opis (opcjonalny)</label>
<InputTextArea id="newBookDescription" class="form-input" @bind-Value="_createBookModel.Description" rows="3" placeholder="Wpisz krótki opis książki..." />
<ValidationMessage For="@(() => _createBookModel.Description)" class="validation-message" />
</div>
@if (!string.IsNullOrEmpty(_createBookError))
{
<div class="error-banner">@_createBookError</div>
}
</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn-nexus secondary" @onclick="CloseCreateBookModal">Anuluj</button>
<button type="submit" class="btn-nexus primary glow-btn" disabled="@_isCreatingBook">
@(_isCreatingBook ? "Tworzenie..." : "Utwórz książkę")
</button>
</div>
</EditForm>
</div>
</div>
}
@code {
private bool _isLoading = true;
private CreatorDashboardDataDto? _dashboardData;
// Create Book Model and state
private bool _isCreateBookModalOpen;
private CreateBookModel _createBookModel = new();
private bool _isCreatingBook;
private string? _createBookError;
// Defensively-scoped state variables for modal isolation
private bool _isPublishModalOpen;
private Guid? _activePublishBookId;
private string _activePublishBookTitle = string.Empty;
private string _customVersionString = string.Empty;
private bool _isSubmitting;
private string? _errorMessage;
// Revisions modal state variables
private bool _isRevisionsModalOpen;
private Guid? _activeRevisionsBookId;
private string _activeRevisionsBookTitle = string.Empty;
private bool _revisionsLoading;
private List<CreatorBookRevisionDto> _revisionsList = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadDashboardDataAsync();
}
}
private async Task LoadDashboardDataAsync()
{
_isLoading = true;
StateHasChanged();
try
{
_dashboardData = await Http.GetFromJsonAsync<CreatorDashboardDataDto>("api/creator/dashboard");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading creator dashboard data.");
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private void NavigateToEditor(CreatorBookDto book)
{
if (book.FirstChapterId.HasValue)
{
NavigationManager.NavigateTo($"/creator/edit/{book.Id}/{book.FirstChapterId.Value}");
}
else
{
NavigationManager.NavigateTo($"/creator/edit/{book.Id}");
}
}
private void OpenPublishModal(CreatorBookDto book)
{
// Explicitly lock context boundaries to the selected book
_activePublishBookId = book.Id;
_activePublishBookTitle = book.Title;
_customVersionString = "v1.0.0";
_errorMessage = null;
_isPublishModalOpen = true;
}
private void ClosePublishModal()
{
_isPublishModalOpen = false;
_activePublishBookId = null;
_activePublishBookTitle = string.Empty;
_customVersionString = string.Empty;
_errorMessage = null;
}
private async Task SubmitPublishVersionAsync()
{
if (!_activePublishBookId.HasValue || string.IsNullOrWhiteSpace(_customVersionString))
{
return;
}
_isSubmitting = true;
_errorMessage = null;
StateHasChanged();
try
{
// Explicitly lock the parameters during sending execution
var bookId = _activePublishBookId.Value;
var response = await Http.PostAsync($"api/creator/books/{bookId}/publish?version={Uri.EscapeDataString(_customVersionString)}", null);
if (response.IsSuccessStatusCode)
{
ClosePublishModal();
await LoadDashboardDataAsync();
}
else
{
_errorMessage = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(_errorMessage))
{
_errorMessage = "Publish version endpoint returned an error.";
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Exception thrown during publication.");
_errorMessage = $"Mutation failed: {ex.Message}";
}
finally
{
_isSubmitting = false;
StateHasChanged();
}
}
private async Task OpenRevisionsModalAsync(CreatorBookDto book)
{
_activeRevisionsBookId = book.Id;
_activeRevisionsBookTitle = book.Title;
_revisionsList = new();
_revisionsLoading = true;
_isRevisionsModalOpen = true;
StateHasChanged();
try
{
_revisionsList = await Http.GetFromJsonAsync<List<CreatorBookRevisionDto>>($"api/creator/books/{book.Id}/revisions") ?? new();
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load revisions.");
}
finally
{
_revisionsLoading = false;
StateHasChanged();
}
}
private void CloseRevisionsModal()
{
_isRevisionsModalOpen = false;
_activeRevisionsBookId = null;
_activeRevisionsBookTitle = string.Empty;
_revisionsList.Clear();
}
private void OpenCreateBookModal()
{
_createBookModel = new CreateBookModel();
_createBookError = null;
_isCreateBookModalOpen = true;
}
private void CloseCreateBookModal()
{
_isCreateBookModalOpen = false;
_createBookError = null;
}
private async Task SubmitCreateBookAsync()
{
_isCreatingBook = true;
_createBookError = null;
StateHasChanged();
try
{
var response = await Http.PostAsJsonAsync("api/creator/books", _createBookModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<CreateBookResponseDto>();
if (result != null)
{
// Reset modal state BEFORE routing to prevent it lingering in the DOM tree
_isCreateBookModalOpen = false;
_createBookError = null;
StateHasChanged();
NavigationManager.NavigateTo($"/creator/edit/{result.BookId}");
}
else
{
_createBookError = "Otrzymano nieprawidłową odpowiedź z serwera.";
}
}
else
{
var errorMsg = await response.Content.ReadAsStringAsync();
_createBookError = !string.IsNullOrWhiteSpace(errorMsg) ? errorMsg : "Wystąpił błąd podczas tworzenia książki.";
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Błąd podczas tworzenia książki.");
_createBookError = $"Krytyczny błąd: {ex.Message}";
}
finally
{
_isCreatingBook = false;
StateHasChanged();
}
}
public class CreateBookModel
{
[Required(ErrorMessage = "Tytuł książki jest wymagany.")]
[StringLength(255, ErrorMessage = "Tytuł książki nie może przekraczać 255 znaków.")]
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
}
}
@@ -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);
}
@@ -7,8 +7,6 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject ISyncService SyncService @inject ISyncService SyncService
@inject IJSRuntime JS
@using Microsoft.JSInterop
@attribute [Authorize] @attribute [Authorize]
@implements IDisposable @implements IDisposable
@@ -56,65 +54,31 @@
<section class="integration-card glass-panel"> <section class="integration-card glass-panel">
<div class="panel-header"> <div class="panel-header">
<h4>Integracja Wiedzy</h4> <h4>Integracja Wiedzy</h4>
<button class="view-toggle-btn" @onclick="ToggleGraphMode" type="button" aria-label="Toggle View"> <NexusIcon Name="arrow-right" Size="16" />
<NexusIcon Name="@(_showSimplifiedList ? "map" : "list")" Size="14" />
<span>@(_showSimplifiedList ? "Pokaż Wykres" : "Pokaż Listę")</span>
</button>
</div> </div>
@if (_showSimplifiedList)
{
<div class="concepts-linear-stack">
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
{
<div class="concept-linear-list">
@foreach (var concept in _profile.MappedConcepts)
{
<div class="concept-linear-item">
<span class="concept-badge @concept.Type.ToLower()">@concept.Type</span>
<span class="concept-text" title="@concept.Content">@concept.Content</span>
</div>
}
</div>
}
else
{
<div class="concepts-empty-list">
<NexusIcon Name="info" Size="20" />
<p>Brak pojęć. Rozpocznij czytanie, aby AI wyodrębniło kluczowe koncepty.</p>
</div>
}
</div>
}
else
{
<div class="graph-placeholder"> <div class="graph-placeholder">
<div class="graph-node central" title="Ośrodek Wiedzy Nexus Reader"></div> <div class="graph-node central" title="Ośrodek Wiedzy Nexus Reader"></div>
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any()) @if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
{ {
<div @key='"satellite-concepts-container"' style="display: contents;">
@for (int i = 0; i < _profile.MappedConcepts.Count; i++) @for (int i = 0; i < _profile.MappedConcepts.Count; i++)
{ {
var concept = _profile.MappedConcepts[i]; var concept = _profile.MappedConcepts[i];
var angle = i * (360.0 / _profile.MappedConcepts.Count); var angle = i * (360.0 / _profile.MappedConcepts.Count);
var dist = 65; var dist = 65;
<div @key="concept.Id" class="graph-node satellite" <div class="graph-node satellite"
style="--angle: @(angle)deg; --dist: @(dist)px;" style="--angle: @(angle)deg; --dist: @(dist)px;"
title="[@concept.Type] @concept.Content" title="[@concept.Type] @concept.Content"
@onmouseover="() => SetHoveredConcept(concept)" @onmouseover="() => SetHoveredConcept(concept)"
@onmouseout="ClearHoveredConcept"> @onmouseout="ClearHoveredConcept">
</div> </div>
} }
</div>
} }
else else
{ {
<div @key='"satellite-placeholders-container"' style="display: contents;"> <div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
<div @key='"satellite-placeholder-0"' class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div> <div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
<div @key='"satellite-placeholder-1"' class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div> <div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
<div @key='"satellite-placeholder-2"' class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
</div>
} }
<div class="active-node-label"> <div class="active-node-label">
@@ -136,7 +100,6 @@
<p class="concept-content">Najedź na węzeł, aby zbadać pojęcie wydobyte przez Nexus AI.</p> <p class="concept-content">Najedź na węzeł, aby zbadać pojęcie wydobyte przez Nexus AI.</p>
</div> </div>
} }
}
</section> </section>
<!-- Quiz Summary --> <!-- Quiz Summary -->
@@ -148,10 +111,10 @@
<div class="quiz-preview"> <div class="quiz-preview">
@if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any()) @if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any())
{ {
<div @key='"quiz-history-list"' class="quiz-history-list"> <div class="quiz-history-list">
@foreach (var quiz in _profile.RecentQuizzes) @foreach (var quiz in _profile.RecentQuizzes)
{ {
<div @key="quiz.Id" class="quiz-history-item"> <div class="quiz-history-item">
<div class="quiz-item-header"> <div class="quiz-item-header">
<span class="quiz-topic">@quiz.Topic</span> <span class="quiz-topic">@quiz.Topic</span>
<span class="quiz-score badge @(quiz.Percentage >= 80 ? "badge-success" : quiz.Percentage >= 50 ? "badge-warning" : "badge-danger")"> <span class="quiz-score badge @(quiz.Percentage >= 80 ? "badge-success" : quiz.Percentage >= 50 ? "badge-warning" : "badge-danger")">
@@ -167,7 +130,7 @@
} }
else else
{ {
<div @key='"empty-quiz-state"' class="empty-quiz-state"> <div class="empty-quiz-state">
<p class="question">Brak rozwiązanych quizów</p> <p class="question">Brak rozwiązanych quizów</p>
<p class="sub-text">Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.</p> <p class="sub-text">Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.</p>
</div> </div>
@@ -177,9 +140,6 @@
</div> </div>
</div> </div>
<!-- Contextual AI Recommendations -->
<ContextualRecommendationsWidget />
<!-- Detailed Content Block Showcase --> <!-- Detailed Content Block Showcase -->
<section class="architecture-guide-panel glass-panel"> <section class="architecture-guide-panel glass-panel">
<div class="panel-header"> <div class="panel-header">
@@ -206,32 +166,6 @@
private UserProfileDto? _profile; private UserProfileDto? _profile;
private MappedConceptDto? _hoveredConcept; private MappedConceptDto? _hoveredConcept;
private string _hoveredConceptLabel = string.Empty; private string _hoveredConceptLabel = string.Empty;
private bool _showSimplifiedList = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var isMobile = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
if (isMobile)
{
_showSimplifiedList = true;
StateHasChanged();
}
}
catch
{
// Fallback for tests or prerendering
}
}
}
private void ToggleGraphMode()
{
_showSimplifiedList = !_showSimplifiedList;
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -17,16 +17,16 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
background: var(--bg-surface); background: #0d0d0d;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.header-grid-bg { .header-grid-bg {
position: absolute; position: absolute;
inset: 0; inset: 0;
background-image: background-image:
linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px); linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 60px 60px; background-size: 60px 60px;
background-position: center; background-position: center;
mask-image: radial-gradient(circle at center, black, transparent 80%); mask-image: radial-gradient(circle at center, black, transparent 80%);
@@ -52,10 +52,10 @@
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 3px solid var(--border); border: 3px solid #1a1a1a;
position: relative; position: relative;
z-index: 2; z-index: 2;
background: var(--bg-surface); background: #222;
} }
.avatar-glow { .avatar-glow {
@@ -78,7 +78,7 @@
font-family: var(--nexus-font-sans); font-family: var(--nexus-font-sans);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 500; font-weight: 500;
color: var(--text-main); color: #ffffff;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: lowercase; text-transform: lowercase;
} }
@@ -103,17 +103,17 @@
.status-pill { .status-pill {
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
background: var(--bg-base); background: rgba(16, 185, 129, 0.05);
border: 1px solid var(--border); border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 100px; border-radius: 100px;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
box-shadow: 0 0 15px rgba(16, 185, 129, 0.05); box-shadow: 0 0 15px rgba(16, 185, 129, 0.1);
} }
.pill-label { color: var(--text-muted); } .pill-label { color: #A0A0A0; }
.pill-value { color: var(--text-main); font-weight: 600; } .pill-value { color: #ffffff; font-weight: 600; }
/* --- Dashboard Content --- */ /* --- Dashboard Content --- */
.dashboard-content { .dashboard-content {
@@ -127,7 +127,7 @@
font-family: var(--nexus-font-serif); font-family: var(--nexus-font-serif);
font-size: 2rem; font-size: 2rem;
margin-bottom: 2rem; margin-bottom: 2rem;
color: var(--text-main); color: #ffffff;
} }
.main-grid { .main-grid {
@@ -137,18 +137,18 @@
} }
.glass-panel { .glass-panel {
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px; border-radius: 12px;
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);
} }
.glass-panel:hover { .glass-panel:hover {
background: var(--bg-surface); background: #1e1e24;
border-color: var(--accent); border-color: rgba(16, 185, 129, 0.2);
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
} }
/* Reading Card */ /* Reading Card */
@@ -161,7 +161,7 @@
.reading-card h3 { .reading-card h3 {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--text-main); color: #E0E0E0;
margin: 0; margin: 0;
} }
@@ -178,7 +178,7 @@
.reading-thumb img { .reading-thumb img {
width: 100%; width: 100%;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15); box-shadow: 0 10px 30px rgba(0,0,0,0.5);
} }
.reading-info { .reading-info {
@@ -196,12 +196,12 @@
.chapter-label { .chapter-label {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: #A0A0A0;
} }
.progress-container { .progress-container {
height: 8px; height: 8px;
background: var(--border); background: rgba(255, 255, 255, 0.05);
border-radius: 4px; border-radius: 4px;
position: relative; position: relative;
} }
@@ -228,13 +228,13 @@
.progress-detail { .progress-detail {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: #666;
} }
.reading-desc { .reading-desc {
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.6; line-height: 1.6;
color: var(--text-muted); color: #888;
margin: 0; margin: 0;
} }
@@ -261,7 +261,7 @@
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-main); color: #E0E0E0;
} }
/* Graph Placeholder */ /* Graph Placeholder */
@@ -325,7 +325,7 @@
.question { .question {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-main); color: #E0E0E0;
} }
.quiz-options { .quiz-options {
@@ -336,14 +336,13 @@
.quiz-option { .quiz-option {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--bg-base); background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px; border-radius: 10px;
font-size: 0.9rem; font-size: 0.9rem;
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
cursor: pointer; cursor: pointer;
color: var(--text-main);
} }
.quiz-option.active { .quiz-option.active {
@@ -373,9 +372,9 @@
} }
.btn-nexus.secondary { .btn-nexus.secondary {
background: var(--bg-base); background: rgba(255, 255, 255, 0.05);
color: var(--text-main); color: #fff;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.1);
} }
.btn-nexus:hover { .btn-nexus:hover {
@@ -418,16 +417,16 @@
} }
.quiz-history-item { .quiz-history-item {
background: var(--bg-surface); background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px; border-radius: 12px;
padding: 1rem; padding: 1rem;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.quiz-history-item:hover { .quiz-history-item:hover {
background: var(--bg-base); background: rgba(255, 255, 255, 0.04);
border-color: var(--border); border-color: rgba(255, 255, 255, 0.1);
} }
.quiz-item-header { .quiz-item-header {
@@ -441,13 +440,13 @@
.quiz-topic { .quiz-topic {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
color: var(--text-main); color: #ffffff;
} }
.quiz-item-meta { .quiz-item-meta {
display: flex; display: flex;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: #666666;
} }
.badge { .badge {
@@ -482,7 +481,7 @@
.empty-quiz-state .sub-text { .empty-quiz-state .sub-text {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: #666666;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -490,8 +489,8 @@
.concept-detail-toast { .concept-detail-toast {
margin-top: 1rem; margin-top: 1rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--bg-base); background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px; border-radius: 12px;
min-height: 80px; min-height: 80px;
display: flex; display: flex;
@@ -515,7 +514,7 @@
.concept-content { .concept-content {
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.4; line-height: 1.4;
color: var(--text-main); color: #E0E0E0;
margin: 0; margin: 0;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@@ -524,29 +523,28 @@
} }
/* Mobile Dashboard Overrides */ /* Mobile Dashboard Overrides */
@media (max-width: 767px) { @media (max-width: 768px) {
.dashboard-content { .dashboard-content {
padding: 0.5rem 0.5rem calc(1.5rem + 64px) !important; padding: 1.25rem 0.75rem;
} }
.profile-header { .profile-header {
padding: 10px 12px; padding: 1.5rem 1rem;
background: var(--bg-surface); border-radius: 16px;
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 10px;
} }
.profile-visual { .profile-visual {
display: grid; flex-direction: row;
grid-template-columns: auto 1fr; flex-wrap: wrap;
justify-content: flex-start;
align-items: center; align-items: center;
gap: 0.5rem; text-align: left;
gap: 1.25rem;
} }
.avatar-wrapper { .avatar-wrapper {
width: 40px; width: 70px;
height: 40px; height: 70px;
margin: 0; margin: 0;
} }
@@ -554,91 +552,45 @@
flex: 1; flex: 1;
} }
.username { .user-name {
font-size: 1.05rem; font-size: 1.5rem;
font-weight: 600; margin-bottom: 0.25rem;
text-align: left;
margin: 0;
letter-spacing: 0px;
} }
.user-role { .user-role {
font-size: 0.8rem; font-size: 0.85rem;
} }
.status-pills { .status-pills {
grid-column: span 2;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%; width: 100%;
gap: 0.25rem; margin-top: 0.5rem;
margin-top: 0.15rem; justify-content: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
} }
.status-pill { .status-pill {
flex: 1; padding: 0.35rem 0.75rem;
display: flex; font-size: 0.75rem;
justify-content: center;
align-items: center;
padding: 0.25rem 0.4rem;
font-size: 0.65rem;
gap: 0.2rem;
white-space: nowrap;
border-radius: 6px;
}
.section-title {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.5px;
margin-top: 4px;
margin-bottom: 8px;
color: var(--text-main);
} }
.main-grid { .main-grid {
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
gap: 0.75rem !important; gap: 1.25rem !important;
} }
.secondary-grid { .secondary-grid {
display: grid !important;
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
gap: 0.75rem !important; gap: 1.25rem !important;
} }
/* Force all widgets to take 100% width and fit inside parent container nicely */ /* Force all widgets to take 100% width and fit inside parent container nicely */
.glass-panel { .glass-panel {
width: 100% !important; width: 100% !important;
padding: 12px !important; padding: 1.25rem !important;
box-sizing: border-box; box-sizing: border-box;
} }
/* Compress Knowledge Graph and Concept stack heights to maximize visible fold space */
.graph-placeholder {
height: 120px;
}
.concepts-linear-stack {
height: 120px !important;
max-height: 120px !important;
overflow-y: auto !important;
padding-right: 4px;
}
.concept-linear-item {
padding: 6px 8px !important;
font-size: 0.75rem !important;
gap: 0.5rem !important;
min-height: auto !important; /* Prevent touch target min-height expansion */
}
.concept-badge {
padding: 2px 6px !important;
font-size: 0.65rem !important;
}
/* Expand touch-targets to 48px min height for interactive elements */ /* Expand touch-targets to 48px min height for interactive elements */
.btn-nexus, .quiz-option, .satellite, .logout-btn, .nav-item, .quiz-item { .btn-nexus, .quiz-option, .satellite, .logout-btn, .nav-item, .quiz-item {
min-height: 48px; min-height: 48px;
@@ -651,8 +603,8 @@
/* --- Architecture Guide Block --- */ /* --- Architecture Guide Block --- */
.architecture-guide-panel { .architecture-guide-panel {
margin-top: 2.5rem; margin-top: 2.5rem;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 2rem;
} }
@@ -666,7 +618,7 @@
.architecture-content h3 { .architecture-content h3 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: #ffffff;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
@@ -674,286 +626,16 @@
.architecture-content p { .architecture-content p {
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.6; line-height: 1.6;
color: var(--text-main); color: #e4e4e7;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.architecture-content code { .architecture-content code {
background: var(--bg-base); background: rgba(255, 255, 255, 0.05);
color: #10b981; color: #10b981;
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
border-radius: 4px; border-radius: 4px;
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ============================================================
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
============================================================ */
.theme-light .username::before,
.theme-light .username::after {
color: var(--accent);
}
.theme-light .avatar-glow {
background: var(--accent);
filter: blur(15px);
opacity: 0.2;
}
.theme-light .progress-container {
background: #e4e1d9;
}
.theme-light .progress-bar {
background: var(--accent);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.2);
}
.theme-light .progress-bubble {
background: var(--accent);
color: #ffffff;
}
.theme-light .graph-node {
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.theme-light .graph-node.central {
background: var(--accent);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.2);
}
.theme-light .graph-node.satellite {
background: rgba(16, 185, 129, 0.15);
border: 1px solid var(--accent);
}
.theme-light .graph-node.satellite:hover {
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
}
.theme-light .active-node-label {
background: rgba(16, 185, 129, 0.06);
border: 1px solid var(--accent);
color: var(--accent);
}
.theme-light .quiz-option.active {
background: rgba(16, 185, 129, 0.06);
border-color: var(--accent);
color: var(--accent);
}
.theme-light .btn-nexus.primary {
background: var(--accent);
color: #0d0d0d;
}
.theme-light .btn-nexus.primary:hover {
background: #059669;
color: #ffffff;
}
.theme-light .empty-icon {
color: var(--accent);
filter: none;
}
.theme-light .badge-success {
background: rgba(16, 185, 129, 0.1);
color: var(--accent);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.theme-light .concept-type {
color: var(--accent);
}
/* --- Concepts Linear Stack for Mobile/Utility Switch --- */
.concepts-linear-stack {
height: 180px;
display: flex;
flex-direction: column;
overflow-y: auto;
gap: 0.5rem;
padding-right: 0.5rem;
}
.concept-linear-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.concept-linear-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.85rem;
color: var(--text-main);
transition: all 0.2s ease;
}
.concept-linear-item:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(0, 255, 153, 0.2);
transform: translateX(2px);
}
.concept-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.concept-badge.rule {
background: rgba(255, 70, 70, 0.1);
color: #ff8b8b;
border: 1px solid rgba(255, 70, 70, 0.2);
}
.concept-badge.definition {
background: rgba(255, 176, 58, 0.1);
color: #ffd18c;
border: 1px solid rgba(255, 176, 58, 0.2);
}
.concept-badge.table {
background: rgba(217, 70, 239, 0.1);
color: #f5d0fe;
border: 1px solid rgba(217, 70, 239, 0.2);
}
.concept-badge.section {
background: rgba(59, 130, 246, 0.1);
color: #93c5fd;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.concept-badge.bridge {
background: rgba(6, 182, 212, 0.1);
color: #67e8f9;
border: 1px solid rgba(6, 182, 212, 0.2);
}
.concept-badge.concept {
background: rgba(0, 210, 196, 0.05);
color: #e0e0e0;
border: 1px solid rgba(0, 210, 196, 0.2);
}
.concept-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.concepts-empty-list {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
gap: 0.5rem;
}
.concepts-empty-list p {
margin: 0;
}
.view-toggle-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 0.35rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.view-toggle-btn:hover {
background: rgba(0, 255, 153, 0.08);
border-color: var(--accent);
color: var(--accent);
}
/* --- Light Theme Overrides for Concepts Stack --- */
.theme-light .concept-linear-item {
background: rgba(0, 0, 0, 0.02);
color: var(--text-main);
}
.theme-light .concept-linear-item:hover {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(16, 185, 129, 0.2);
}
.theme-light .concept-badge.rule {
background: rgba(220, 38, 38, 0.05);
color: #991b1b;
border-color: rgba(220, 38, 38, 0.15);
}
.theme-light .concept-badge.definition {
background: rgba(217, 119, 6, 0.05);
color: #92400e;
border-color: rgba(217, 119, 6, 0.15);
}
.theme-light .concept-badge.table {
background: rgba(192, 132, 252, 0.05);
color: #6b21a8;
border-color: rgba(192, 132, 252, 0.15);
}
.theme-light .concept-badge.section {
background: rgba(37, 99, 235, 0.05);
color: #1e3a8a;
border-color: rgba(37, 99, 235, 0.15);
}
.theme-light .concept-badge.bridge {
background: rgba(8, 145, 178, 0.05);
color: #155e75;
border-color: rgba(8, 145, 178, 0.15);
}
.theme-light .concept-badge.concept {
background: rgba(13, 148, 136, 0.03);
color: #115e59;
border-color: rgba(13, 148, 136, 0.1);
}
.theme-light .view-toggle-btn {
background: rgba(0, 0, 0, 0.02);
}
.theme-light .view-toggle-btn:hover {
background: rgba(16, 185, 129, 0.08);
border-color: var(--accent);
color: var(--accent);
}
@@ -7,13 +7,11 @@
@using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Models @using NexusReader.UI.Shared.Models
@using Microsoft.Extensions.Logging
@using System.Net.Http.Json @using System.Net.Http.Json
@inject HttpClient Http @inject HttpClient Http
@inject IKnowledgeService KnowledgeService @inject IKnowledgeService KnowledgeService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject ILibraryStateService LibraryStateService @inject ILibraryStateService LibraryStateService
@inject ILogger<Intelligence> Logger
<div class="intelligence-page"> <div class="intelligence-page">
<div class="intelligence-layout"> <div class="intelligence-layout">
@@ -132,7 +130,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[Intelligence] Failed to load books."); Console.WriteLine($"[Intelligence] Failed to load books: {ex.Message}");
} }
} }
@@ -1,7 +1,7 @@
.intelligence-page { .intelligence-page {
margin: -2.5rem; margin: -2.5rem;
height: 100vh; height: 100vh;
background: var(--bg-base); background: #121214;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -45,11 +45,11 @@
background: transparent; background: transparent;
} }
.chat-thread-container::-webkit-scrollbar-thumb { .chat-thread-container::-webkit-scrollbar-thumb {
background: var(--accent-glow); background: rgba(16, 185, 129, 0.2);
border-radius: 4px; border-radius: 4px;
} }
.chat-thread-container::-webkit-scrollbar-thumb:hover { .chat-thread-container::-webkit-scrollbar-thumb:hover {
background: var(--accent); background: rgba(16, 185, 129, 0.4);
} }
.chat-bubbles-scroll { .chat-bubbles-scroll {
@@ -78,7 +78,7 @@
.welcome-prompt { .welcome-prompt {
font-family: var(--nexus-font-sans, inherit); font-family: var(--nexus-font-sans, inherit);
color: var(--text-main); color: #e4e4e7;
font-size: 1.35rem; font-size: 1.35rem;
font-weight: 500; font-weight: 500;
letter-spacing: -0.2px; letter-spacing: -0.2px;
@@ -87,7 +87,7 @@
/* Input Controls */ /* Input Controls */
.chat-input-controls { .chat-input-controls {
padding: 1.5rem 4rem 3rem 4rem; padding: 1.5rem 4rem 3rem 4rem;
background: linear-gradient(to top, var(--bg-base) 70%, transparent); background: linear-gradient(to top, #121214 70%, rgba(18, 18, 20, 0));
flex-shrink: 0; flex-shrink: 0;
} }
@@ -117,13 +117,13 @@
gap: 0.6rem; gap: 0.6rem;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: #8b8273;
} }
.nexus-select { .nexus-select {
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-main); color: #e4e4e7;
padding: 0.4rem 2rem 0.4rem 0.75rem; padding: 0.4rem 2rem 0.4rem 0.75rem;
border-radius: 8px; border-radius: 8px;
outline: none; outline: none;
@@ -138,38 +138,38 @@
} }
.nexus-select:focus { .nexus-select:focus {
border-color: var(--accent); border-color: #10b981;
box-shadow: 0 0 8px var(--accent-glow); box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
} }
.input-field-group { .input-field-group {
display: flex; display: flex;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px; border-radius: 12px;
padding: 0.4rem; padding: 0.4rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
} }
.input-field-group:focus-within { .input-field-group:focus-within {
border-color: var(--accent); border-color: rgba(16, 185, 129, 0.5);
background: var(--bg-surface); background: #1a1a1e;
box-shadow: 0 10px 35px var(--accent-glow); box-shadow: 0 10px 35px rgba(16, 185, 129, 0.1);
} }
.nexus-input { .nexus-input {
flex-grow: 1; flex-grow: 1;
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-main); color: #ffffff;
font-size: 0.975rem; font-size: 0.975rem;
outline: none; outline: none;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
.nexus-input::placeholder { .nexus-input::placeholder {
color: var(--text-muted); color: #8b8273;
} }
.search-btn { .search-btn {
@@ -180,31 +180,29 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 8px; border-radius: 8px;
background: var(--accent); background: #10b981;
border: none; border: none;
color: var(--bg-surface); color: #121214;
cursor: pointer; cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
} }
.search-btn:hover:not(:disabled) { .search-btn:hover:not(:disabled) {
background: var(--accent); background: #0d9668;
opacity: 0.9;
transform: scale(1.02); transform: scale(1.02);
} }
.search-btn:disabled { .search-btn:disabled {
background: var(--bg-base); background: rgba(26, 26, 30, 0.8);
color: var(--text-muted); color: rgba(255, 255, 255, 0.2);
opacity: 0.4; border: 1px solid rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
cursor: not-allowed; cursor: not-allowed;
} }
/* Typing / Loading Indicators */ /* Typing / Loading Indicators */
.message-bubble.pending-bubble { .message-bubble.pending-bubble {
border-color: var(--accent-glow); border-color: rgba(16, 185, 129, 0.25);
background: var(--accent-glow); background: rgba(16, 185, 129, 0.03);
max-width: 450px; max-width: 450px;
} }
@@ -218,7 +216,7 @@
.typing-indicator span { .typing-indicator span {
width: 7px; width: 7px;
height: 7px; height: 7px;
background: var(--accent); background: #10b981;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
animation: typing-bounce 1.4s infinite ease-in-out both; animation: typing-bounce 1.4s infinite ease-in-out both;
@@ -229,16 +227,16 @@
.loading-label { .loading-label {
font-size: 0.825rem; font-size: 0.825rem;
color: var(--text-muted); color: rgba(255, 255, 255, 0.45);
font-style: italic; font-style: italic;
} }
.btn-spinner { .btn-spinner {
width: 18px; width: 18px;
height: 18px; height: 18px;
border: 2px solid var(--border); border: 2px solid rgba(18, 18, 20, 0.1);
border-radius: 50%; border-radius: 50%;
border-top-color: var(--accent); border-top-color: #121214;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@@ -262,66 +260,3 @@
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
/* ============================================================
LIGHT THEME OVERRIDES "Warm Paper / Soft Sepia"
============================================================ */
.theme-light .welcome-prompt {
color: var(--text-main);
}
.theme-light .welcome-icon svg {
stroke: var(--text-muted);
}
.theme-light .welcome-icon svg circle {
fill: var(--text-muted);
}
.theme-light .welcome-icon svg path[stroke^="rgba(139"] {
stroke: rgba(120, 113, 108, 0.4);
}
.theme-light .welcome-icon svg path[stroke^="rgba(139"][stroke-dasharray] {
stroke: rgba(120, 113, 108, 0.3);
}
.theme-light .input-field-group {
background: var(--bg-surface);
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.08);
}
.theme-light .input-field-group:focus-within {
border-color: var(--accent);
box-shadow: 0 10px 35px rgba(16, 185, 129, 0.15);
}
.theme-light .nexus-input {
color: var(--text-main);
}
.theme-light .nexus-input::placeholder {
color: var(--text-muted);
}
.theme-light .nexus-select {
background-color: var(--bg-surface);
border-color: var(--border);
color: var(--text-main);
}
.theme-light .nexus-select:focus {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.theme-light .chat-thread-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
}
.theme-light .chat-thread-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
@@ -4,12 +4,10 @@
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using Microsoft.Extensions.Logging
@using System.Net.Http.Json @using System.Net.Http.Json
@inject HttpClient Http @inject HttpClient Http
@inject IReaderNavigationService ReaderNavigation @inject IReaderNavigationService ReaderNavigation
@inject ILibraryStateService LibraryStateService @inject ILibraryStateService LibraryStateService
@inject ILogger<MyBooks> Logger
<div class="my-books-page"> <div class="my-books-page">
<header class="my-books-header"> <header class="my-books-header">
@@ -142,7 +140,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[MyBooks] Failed to load books."); Console.WriteLine($"[MyBooks] Failed to load books: {ex.Message}");
if (OperatingSystem.IsBrowser()) if (OperatingSystem.IsBrowser())
{ {
_isLoading = false; _isLoading = false;
@@ -19,13 +19,13 @@
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--text-main); color: #ffffff;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.header-title-section .subtitle { .header-title-section .subtitle {
font-size: 1rem; font-size: 1rem;
color: var(--text-muted); color: #a1a1aa;
margin: 0; margin: 0;
} }
@@ -67,27 +67,27 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
border-radius: 12px; border-radius: 12px;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
} }
.book-card:hover { .book-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
border-color: var(--accent); border-color: rgba(16, 185, 129, 0.2);
} }
.book-cover-container { .book-cover-container {
position: relative; position: relative;
height: 360px; height: 360px;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.2);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.book-cover { .book-cover {
@@ -145,7 +145,7 @@
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
margin: 0 0 0.4rem 0; margin: 0 0 0.4rem 0;
color: var(--text-main); color: #ffffff;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -154,7 +154,7 @@
.book-author { .book-author {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted); color: #a1a1aa;
margin: 0 0 1.25rem 0; margin: 0 0 1.25rem 0;
} }
@@ -179,7 +179,7 @@
.progress-bar { .progress-bar {
height: 6px; height: 6px;
background: var(--border); background: rgba(255, 255, 255, 0.05);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
} }
@@ -192,7 +192,7 @@
.progress-text { .progress-text {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: #a1a1aa;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -232,14 +232,14 @@
justify-content: center; justify-content: center;
padding: 5rem 2rem; padding: 5rem 2rem;
text-align: center; text-align: center;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px; border-radius: 12px;
} }
.empty-icon-pulse { .empty-icon-pulse {
margin-bottom: 2rem; margin-bottom: 2rem;
color: var(--text-muted); color: #a1a1aa;
animation: pulse 3s infinite alternate; animation: pulse 3s infinite alternate;
} }
@@ -247,11 +247,11 @@
font-family: var(--nexus-font-serif); font-family: var(--nexus-font-serif);
font-size: 1.8rem; font-size: 1.8rem;
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--text-main); color: #ffffff;
} }
.empty-state-container p { .empty-state-container p {
color: var(--text-muted); color: #a1a1aa;
max-width: 400px; max-width: 400px;
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
} }
@@ -278,14 +278,14 @@
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
height: 480px; height: 480px;
background: var(--bg-surface); background: #1a1a1e;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.05);
opacity: 0.6; opacity: 0.6;
} }
.skeleton-cover { .skeleton-cover {
height: 360px; height: 360px;
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%); background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: loading 1.5s infinite; animation: loading 1.5s infinite;
} }
@@ -298,7 +298,7 @@
} }
.skeleton-line { .skeleton-line {
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%); background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: loading 1.5s infinite; animation: loading 1.5s infinite;
border-radius: 4px; border-radius: 4px;
@@ -336,9 +336,9 @@
gap: 1.25rem; gap: 1.25rem;
padding: 1.25rem 2.25rem; padding: 1.25rem 2.25rem;
border-radius: 40px; border-radius: 40px;
background: var(--bg-surface); background: rgba(13, 13, 15, 0.85);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.08);
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@@ -354,7 +354,7 @@
.loader-text { .loader-text {
font-weight: 500; font-weight: 500;
color: var(--text-main); color: #ffffff;
font-size: 0.95rem; font-size: 0.95rem;
} }
@@ -383,65 +383,3 @@
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; } from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
to { transform: translate(-50%, -50%) scale(1); opacity: 1; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
} }
/* ============================================
LIGHT THEME OVERRIDES Warm Paper / Soft Sepia
============================================ */
.theme-light .book-card:hover {
box-shadow: 0 12px 30px rgba(139, 130, 115, 0.15);
}
.theme-light .book-cover-container {
background: rgba(0, 0, 0, 0.03);
}
.theme-light .cover-overlay {
background: rgba(0, 0, 0, 0.5);
}
.theme-light .book-card:hover .read-action {
color: #292524;
}
.theme-light .progress-bar {
background: #e4e1d9;
}
@media (max-width: 767px) {
.my-books-page {
padding: 1.5rem 1rem calc(1.5rem + 72px + env(safe-area-inset-bottom, 0px)) !important;
}
.my-books-header {
margin-bottom: 1.5rem;
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.header-title-section h1 {
font-size: 1.75rem;
text-align: center;
}
.header-title-section .subtitle {
text-align: center;
}
.add-book-trigger {
width: 100%;
text-align: center;
padding: 0.9rem 1.5rem;
}
.books-grid, .loading-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.25rem;
}
.book-cover-container {
height: 200px !important;
}
}
@@ -72,40 +72,3 @@
from { opacity: 0; transform: translateY(15px); } from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* ============================================================
LIGHT THEME OVERRIDES "Warm Paper / Soft Sepia"
============================================================ */
.theme-light .settings-page > h1 {
background: none;
-webkit-text-fill-color: initial;
color: var(--text-main);
}
.theme-light .settings-page > p {
color: var(--text-muted);
}
.theme-light .settings-section h2 {
color: var(--text-main);
}
.theme-light .settings-section p {
color: var(--text-muted);
}
.theme-light .diag-btn {
background: rgba(16, 185, 129, 0.05);
color: var(--accent);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.theme-light .diag-btn:hover {
background: var(--accent);
color: #ffffff;
border-color: var(--accent);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
@@ -1,23 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NexusReader.Application.Queries.Recommendations;
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// Provides contextual book recommendations based on the user's active reading state.
/// Abstracts the HTTP transport layer from Blazor UI components.
/// </summary>
public interface IRecommendationService
{
/// <summary>
/// Fetches contextual recommendations for the authenticated user.
/// </summary>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// A list of <see cref="RecommendationDto"/> on success, or an empty list when none are available.
/// Returns <c>null</c> if the request fails due to a transport or server error.
/// </returns>
Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default);
}
@@ -1,14 +1,9 @@
using NexusReader.Domain.Enums;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public interface IThemeService public interface IThemeService
{ {
ThemeMode Mode { get; }
bool IsLightMode { get; } bool IsLightMode { get; }
event Action<ThemeMode>? OnThemeChanged; event Func<Task>? OnThemeChanged;
Task InitializeAsync(); Task InitializeAsync();
Task SetThemeAsync(ThemeMode mode);
Task ToggleTheme(); Task ToggleTheme();
} }
@@ -1,155 +1,42 @@
using Microsoft.JSInterop; using Microsoft.JSInterop;
using NexusReader.Domain.Enums;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public sealed class ThemeService : IThemeService public sealed class ThemeService : IThemeService
{ {
private readonly IJSRuntime _jsRuntime; private readonly IJSRuntime _jsRuntime;
private readonly IUserPreferenceStore _userPreferenceStore; public bool IsLightMode { get; private set; } = false;
private readonly SemaphoreSlim _semaphore = new(1, 1); public event Func<Task>? OnThemeChanged;
private bool _isInitialized;
private bool _systemPrefersLight;
public ThemeMode Mode { get; private set; } = ThemeMode.System; public ThemeService(IJSRuntime jsRuntime)
public bool IsLightMode => Mode == ThemeMode.LightSepia || (Mode == ThemeMode.System && _systemPrefersLight);
public event Action<ThemeMode>? OnThemeChanged;
public ThemeService(IJSRuntime jsRuntime, IUserPreferenceStore userPreferenceStore)
{ {
_jsRuntime = jsRuntime; _jsRuntime = jsRuntime;
_userPreferenceStore = userPreferenceStore;
} }
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
if (_isInitialized) return;
await _semaphore.WaitAsync();
try try
{ {
if (_isInitialized) return; IsLightMode = await _jsRuntime.InvokeAsync<bool>("themeInterop.isLightMode");
if (OnThemeChanged != null) await OnThemeChanged();
ThemeMode localMode = ThemeMode.System;
try
{
var cachedThemeVal = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "theme-mode");
if (Enum.TryParse<ThemeMode>(cachedThemeVal, out var parsedMode))
{
localMode = parsedMode;
}
else if (cachedThemeVal == "light" || cachedThemeVal == "theme-light")
{
localMode = ThemeMode.LightSepia;
}
else if (cachedThemeVal == "dark" || cachedThemeVal == "theme-dark")
{
localMode = ThemeMode.Dark;
}
_systemPrefersLight = await _jsRuntime.InvokeAsync<bool>("themeInterop.isSystemLight");
} }
catch catch
{ {
// Silent catch for pre-rendering or unit tests // Fail silently during prerendering or if JS is not available yet
}
Mode = localMode;
_isInitialized = true;
await ApplyThemeToDomAsync(Mode);
// Asynchronously sync with the cloud to check for updates from other devices
_ = Task.Run(async () =>
{
try
{
var cloudResult = await _userPreferenceStore.GetThemePreferenceAsync();
if (cloudResult.IsSuccess && cloudResult.Value != Mode)
{
await SetThemeInternalAsync(cloudResult.Value, saveToCloud: false);
}
}
catch
{
// Fail silently for background task/network errors
}
});
}
finally
{
_semaphore.Release();
}
}
public async Task SetThemeAsync(ThemeMode mode)
{
await SetThemeInternalAsync(mode, saveToCloud: true);
}
private async Task SetThemeInternalAsync(ThemeMode mode, bool saveToCloud)
{
await _semaphore.WaitAsync();
try
{
if (Mode == mode && _isInitialized) return;
Mode = mode;
_isInitialized = true;
await ApplyThemeToDomAsync(mode);
OnThemeChanged?.Invoke(mode);
if (saveToCloud)
{
_ = Task.Run(async () =>
{
try
{
await _userPreferenceStore.SaveThemePreferenceAsync(mode);
}
catch
{
// Fail silently for background cloud sync errors
}
});
}
}
finally
{
_semaphore.Release();
} }
} }
public async Task ToggleTheme() public async Task ToggleTheme()
{ {
var nextMode = IsLightMode ? ThemeMode.Dark : ThemeMode.LightSepia; IsLightMode = !IsLightMode;
await SetThemeAsync(nextMode);
}
private async Task ApplyThemeToDomAsync(ThemeMode mode)
{
try try
{ {
string themeClass = "theme-dark"; // Default await _jsRuntime.InvokeVoidAsync("themeInterop.setLightMode", IsLightMode);
if (mode == ThemeMode.LightSepia)
{
themeClass = "theme-light";
}
else if (mode == ThemeMode.System)
{
themeClass = _systemPrefersLight ? "theme-light" : "theme-dark";
}
await _jsRuntime.InvokeVoidAsync("themeInterop.setCachedTheme", themeClass, ((int)mode).ToString());
} }
catch catch
{ {
// Silent catch for pre-rendering // Fail silently
} }
if (OnThemeChanged != null) await OnThemeChanged();
} }
} }
-2
View File
@@ -20,5 +20,3 @@
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.Application.Queries.Reader @using NexusReader.Application.Queries.Reader
@using NexusReader.Application.Queries.Recommendations
@using NexusReader.Domain.Enums
+28 -616
View File
@@ -1,29 +1,17 @@
@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) */ --nexus-neon: #00ff99;
--bg-base: #121214; --nexus-neon-glow: rgba(0, 255, 153, 0.3);
--bg-surface: #1a1a1e; --nexus-bg: #121214;
--text-main: #ffffff; --nexus-card: #1a1a1e;
--text-muted: #a1a1aa; --nexus-text: #ffffff;
--accent: #00ff99;
--accent-glow: rgba(0, 255, 153, 0.3);
--border: rgba(255, 255, 255, 0.05);
/* Legacy mapping for backwards compatibility */
--nexus-neon: var(--accent);
--nexus-neon-glow: var(--accent-glow);
--nexus-bg: var(--bg-base);
--nexus-card: var(--bg-surface);
--nexus-text: var(--text-main);
--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);
--nexus-accent: var(--accent);
/* Graph Nodes Theme Custom Properties (Dark Mode) */ /* Graph Nodes Theme Custom Properties (Dark Mode) */
--nexus-graph-bg: radial-gradient(circle, #1a1a1a 0%, #121212 100%); --nexus-graph-bg: radial-gradient(circle, #1a1a1a 0%, #121212 100%);
@@ -61,42 +49,38 @@
--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;
} }
/* Global Semantic Theme Mapping */ /* Global Semantic Theme Mapping */
:root { --nexus-primary: var(--nexus-neon);
--nexus-primary: var(--nexus-neon); --nexus-primary-glow: var(--nexus-neon-glow);
--nexus-primary-glow: var(--nexus-neon-glow); --nexus-primary-hover: #00e688;
--nexus-primary-hover: #00e688;
/* Standard Layout Tokens */ /* Standard Layout Tokens */
--radius-sm: 8px; --radius-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
--radius-lg: 16px; --radius-lg: 16px;
--radius-xl: 20px; --radius-xl: 20px;
/* Safe Area Insets with fallbacks */ /* Safe Area Insets with fallbacks */
--safe-area-inset-top: env(safe-area-inset-top, 0px); --safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px); --safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px); --safe-area-inset-right: env(safe-area-inset-right, 0px);
/* Transitions */ /* Transitions */
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); --nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Global Glassmorphism with Fallback */ /* Global Glassmorphism with Fallback */
.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);
@@ -149,40 +133,11 @@
} }
.theme-dark {
/* Semantic design tokens - Modern Deep Dark */
--bg-base: #121214;
--bg-surface: #1a1a1e;
--text-main: #ffffff;
--text-muted: #a1a1aa;
--accent: #00ff99;
--accent-glow: rgba(0, 255, 153, 0.3);
--border: rgba(255, 255, 255, 0.05);
/* Legacy mapping for backwards compatibility */
--nexus-bg: var(--bg-base);
--nexus-card: var(--bg-surface);
--nexus-text: var(--text-main);
--nexus-selection: rgba(0, 255, 153, 0.25);
--nexus-accent: var(--accent);
}
.theme-light { .theme-light {
/* Semantic design tokens - Warm Paper / Soft Sepia */ --nexus-bg: #f4f1ea;
--bg-base: #f4f1ea; --nexus-card: #ffffff;
--bg-surface: #ffffff; --nexus-text: #2d2a26;
--text-main: #2d2a26;
--text-muted: #78716c;
--accent: #10b981;
--accent-glow: rgba(16, 185, 129, 0.2);
--border: rgba(0, 0, 0, 0.08);
/* Legacy mapping for backwards compatibility */
--nexus-bg: var(--bg-base);
--nexus-card: var(--bg-surface);
--nexus-text: var(--text-main);
--nexus-selection: rgba(16, 185, 129, 0.18); --nexus-selection: rgba(16, 185, 129, 0.18);
--nexus-accent: var(--accent);
/* Graph Nodes Theme Custom Properties (Light Mode) */ /* Graph Nodes Theme Custom Properties (Light Mode) */
--nexus-graph-bg: radial-gradient(circle, #ffffff 0%, #e8e4da 100%); --nexus-graph-bg: radial-gradient(circle, #ffffff 0%, #e8e4da 100%);
@@ -449,546 +404,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
};
}
+7 -18
View File
@@ -1,25 +1,14 @@
window.themeInterop = { window.themeInterop = {
isSystemLight: function () {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
},
setCachedTheme: function (themeClass, modeValue) {
localStorage.setItem('theme-mode', modeValue);
localStorage.setItem('theme', themeClass === 'theme-light' ? 'light' : 'dark');
if (themeClass === 'theme-light') {
document.documentElement.classList.add('theme-light');
document.documentElement.classList.remove('theme-dark');
} else {
document.documentElement.classList.add('theme-dark');
document.documentElement.classList.remove('theme-light');
}
},
isLightMode: function () { isLightMode: function () {
return document.documentElement.classList.contains('theme-light'); return document.documentElement.classList.contains('theme-light');
}, },
setLightMode: function (isLight) { setLightMode: function (isLight) {
var themeClass = isLight ? 'theme-light' : 'theme-dark'; if (isLight) {
var modeValue = isLight ? '2' : '1'; document.documentElement.classList.add('theme-light');
this.setCachedTheme(themeClass, modeValue); localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.remove('theme-light');
localStorage.setItem('theme', 'dark');
}
} }
}; };
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -41
View File
@@ -17,8 +17,7 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Platform & UI Services // Platform & UI Services
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, WebStorageService>(); builder.Services.AddScoped<INativeStorageService, WebStorageService>();
builder.Services.AddSingleton<IUserPreferenceStore, CloudUserPreferenceStore>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddSingleton<IThemeService, ThemeService>();
// Feature settings (avoiding direct raw IConfiguration injection in client pages) // Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings(); var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings); builder.Services.AddSingleton(featureSettings);
@@ -43,7 +42,6 @@ builder.Services.AddCascadingAuthenticationState();
// AI & Content Services // AI & Content Services
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>(); builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
builder.Services.AddScoped<IConceptsMapService, WasmConceptsMapService>(); builder.Services.AddScoped<IConceptsMapService, WasmConceptsMapService>();
builder.Services.AddScoped<IRecommendationService, RecommendationService>();
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>(); builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client => builder.Services.AddHttpClient("NexusAPI", client =>
@@ -62,10 +60,6 @@ builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepos
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository()); builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster()); builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor()); builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
builder.Services.AddSingleton<IUserLibraryStore>(new ThrowingUserLibraryStore());
builder.Services.AddSingleton<IVectorSearchStore>(new ThrowingVectorSearchStore());
builder.Services.Configure<NexusReader.Application.Common.RagMonetizationOptions>(builder.Configuration.GetSection(NexusReader.Application.Common.RagMonetizationOptions.SectionName));
builder.Services.AddSingleton<IChatClient>(new ThrowingChatClient());
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddScoped<IEpubReader, WasmEpubReader>(); builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
@@ -139,37 +133,3 @@ public class ThrowingEpubExtractor : IEpubExtractor
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client."); => throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
} }
public class ThrowingUserLibraryStore : IUserLibraryStore
{
public Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
public Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
}
public class ThrowingVectorSearchStore : IVectorSearchStore
{
public Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
public Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
public Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
}
public class ThrowingChatClient : IChatClient
{
public void Dispose() { }
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
public object? GetService(Type serviceType, object? serviceKey = null) => null;
}
@@ -1,61 +0,0 @@
using System.Net.Http;
using System.Net.Http.Json;
using FluentResults;
using NexusReader.Application.DTOs.User;
using NexusReader.Domain.Enums;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Client.Services;
public class CloudUserPreferenceStore : IUserPreferenceStore
{
private readonly IHttpClientFactory _httpClientFactory;
public CloudUserPreferenceStore(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
{
try
{
var client = CreateClient();
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
if (response.IsSuccessStatusCode)
{
return Result.Ok();
}
var error = await response.Content.ReadAsStringAsync();
return Result.Fail($"Failed to save cloud theme preference: {error}");
}
catch (Exception ex)
{
return Result.Fail(new Error("Network error saving theme preference to cloud.").CausedBy(ex));
}
}
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
{
try
{
var client = CreateClient();
var response = await client.GetAsync("identity/profile");
if (response.IsSuccessStatusCode)
{
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
return profile != null
? Result.Ok(profile.ThemePreference)
: Result.Fail("Failed to deserialize profile response.");
}
return Result.Fail($"Failed to fetch theme preference from cloud: {response.ReasonPhrase}");
}
catch (Exception ex)
{
return Result.Fail(new Error("Network error retrieving theme preference from cloud.").CausedBy(ex));
}
}
}
@@ -1,74 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Common;
using NexusReader.Application.Queries.Recommendations;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Client.Services;
/// <summary>
/// WASM implementation of <see cref="IRecommendationService"/> that fetches contextual recommendations
/// from the <c>/api/recommendations</c> server endpoint via a named <c>NexusAPI</c> HTTP client.
/// </summary>
internal sealed class RecommendationService : IRecommendationService
{
private readonly HttpClient _http;
private readonly ILogger<RecommendationService> _logger;
/// <summary>
/// Initializes a new instance of <see cref="RecommendationService"/>.
/// </summary>
public RecommendationService(HttpClient http, ILogger<RecommendationService> logger)
{
_http = http;
_logger = logger;
}
/// <inheritdoc />
public async Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync("/api/recommendations", cancellationToken);
if (response.StatusCode == HttpStatusCode.NoContent || response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogInformation("[RecommendationService] No recommendations available (status {StatusCode}).", response.StatusCode);
return new List<RecommendationDto>();
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync(
AppJsonContext.Default.ContextualRecommendationResponse,
cancellationToken);
if (result is null)
{
_logger.LogWarning("[RecommendationService] Deserialised response was null.");
return new List<RecommendationDto>();
}
_logger.LogInformation("[RecommendationService] Received {Count} recommendations.", result.Recommendations.Count);
return result.Recommendations;
}
catch (HttpRequestException httpEx)
{
_logger.LogError(httpEx, "[RecommendationService] HTTP error fetching recommendations: {Message}", httpEx.Message);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "[RecommendationService] Unexpected error fetching recommendations.");
return null;
}
}
}
+3 -18
View File
@@ -11,29 +11,14 @@
<script> <script>
(function () { (function () {
try { const savedTheme = localStorage.getItem('theme');
var themeMode = localStorage.getItem('theme-mode'); const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var savedTheme = localStorage.getItem('theme'); const isLight = savedTheme === 'light' || (!savedTheme && !systemPrefersDark);
var isLight = false;
if (themeMode === '2' || savedTheme === 'light') {
isLight = true;
} else if (themeMode === '1' || savedTheme === 'dark') {
isLight = false;
} else {
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
}
if (isLight) { if (isLight) {
document.documentElement.classList.add('theme-light'); document.documentElement.classList.add('theme-light');
document.documentElement.classList.remove('theme-dark');
} else { } else {
document.documentElement.classList.add('theme-dark');
document.documentElement.classList.remove('theme-light'); document.documentElement.classList.remove('theme-light');
} }
} catch (e) {
// Fail silently
}
})(); })();
</script> </script>
+1 -279
View File
@@ -52,9 +52,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>(); builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
builder.Services.AddScoped<IUserPreferenceStore, NexusReader.Web.Services.ServerUserPreferenceStore>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IThemeService, NexusReader.Web.Services.ServerThemeService>();
builder.Services.AddScoped<IRecommendationService, NexusReader.Web.Services.ServerRecommendationService>();
// Feature settings (avoiding direct raw IConfiguration injection in client pages) // Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings(); var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings); builder.Services.AddSingleton(featureSettings);
@@ -91,10 +89,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 +293,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 +491,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,
@@ -886,99 +753,6 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator)
return Results.Ok(result.Value); return Results.Ok(result.Value);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/identity/theme", async (
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.User.UpdateThemeRequest request,
ClaimsPrincipal user,
IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var result = await mediator.Send(new NexusReader.Application.Commands.User.UpdateThemeCommand(userId, request.Mode));
if (result.IsFailed) return Results.BadRequest(result.Errors.FirstOrDefault()?.Message);
return Results.Ok();
}).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 +804,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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More