Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f79eb0b2e | |||
| 4432c901f0 | |||
| c94e8f0acb | |||
| ec3fc52a73 | |||
| 9fddafa423 | |||
| 9291bde531 | |||
| 1d6862016d | |||
| bcd5daa3a0 | |||
| f6819d50b7 | |||
| f18663426b | |||
| 081c6f7940 | |||
| 00004ce433 |
@@ -4,6 +4,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageVersion Include="Markdig" Version="0.38.0" />
|
||||
<PackageVersion Include="Mapster" Version="10.0.7" />
|
||||
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="MediatR" Version="12.1.1" />
|
||||
|
||||
@@ -26,6 +26,7 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
|
||||
@@ -46,4 +46,9 @@ version: 1.0
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Git Workflow & Integration**
|
||||
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea` MCP server.
|
||||
> All tasks originating from the repository must be performed on a separate branch. Every new chat must be launched from the `develop` branch. To connect to the Git repository, use the `gitea` MCP server.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Docker Lifecycle Management**
|
||||
> Before starting work, only the web (nexus) container needs to be stopped to prevent port/application conflicts (e.g., `./run-stage.sh --stop --nexus-only` or `-s -n`); database containers (PostgreSQL, Neo4j, Qdrant) should continue to run to support local development/debugging. After finishing work, a new version of the web container from the current branch should be rebuilt and restarted via `./run-stage.sh --nexus-only` (or `-n`).
|
||||
|
||||
|
||||
@@ -36,3 +36,13 @@ Run test suite:
|
||||
```bash
|
||||
dotnet test --no-restore
|
||||
```
|
||||
|
||||
## 🗄️ Database Migrations
|
||||
|
||||
Automatic database migrations at startup (`MigrateAsync()`) have been disabled to ensure compatibility with Native AOT compilation and prevent locking issues in multi-instance environments.
|
||||
|
||||
To apply database migrations locally, run the EF Core migration command from the solution root:
|
||||
|
||||
```bash
|
||||
dotnet ef database update --project src/NexusReader.Infrastructure --startup-project src/NexusReader.Web
|
||||
```
|
||||
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
- ASPNETCORE_ENVIRONMENT=Staging
|
||||
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
||||
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
|
||||
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
||||
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# -------------------------------------------------------------
|
||||
# Staging Deploy & Orchestration Helper for NexusReader
|
||||
# -------------------------------------------------------------
|
||||
set -e
|
||||
|
||||
NEXUS_ONLY=false
|
||||
STOP=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--nexus-only|-n)
|
||||
NEXUS_ONLY=true
|
||||
;;
|
||||
--stop|-s)
|
||||
STOP=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ENV_FILE=".env.stage"
|
||||
TEMPLATE_FILE=".env.stage.template"
|
||||
COMPOSE_FILE="docker-compose.stage.yml"
|
||||
|
||||
if [ "$STOP" = true ]; then
|
||||
echo "🛑 Stopping staging environment..."
|
||||
if [ ! -f "$ENV_FILE" ] && [ -f "$TEMPLATE_FILE" ]; then
|
||||
cp "$TEMPLATE_FILE" "$ENV_FILE"
|
||||
fi
|
||||
if [ "$NEXUS_ONLY" = true ]; then
|
||||
echo "🧹 Stopping and removing only the web (nexus) container..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
||||
else
|
||||
echo "🧹 Stopping all containers..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
||||
docker compose down --remove-orphans 2>/dev/null || true
|
||||
fi
|
||||
echo "✅ Staging environment stopped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🏁 Starting staging environment orchestration..."
|
||||
if [ "$NEXUS_ONLY" = true ]; then
|
||||
echo "ℹ️ Mode: --nexus-only (only the web/nexus application container will be modified)"
|
||||
fi
|
||||
|
||||
# 1. Create .env.stage if it doesn't exist
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f "$TEMPLATE_FILE" ]; then
|
||||
echo "📄 Creating $ENV_FILE from $TEMPLATE_FILE..."
|
||||
cp "$TEMPLATE_FILE" "$ENV_FILE"
|
||||
else
|
||||
echo "❌ Error: Template file $TEMPLATE_FILE not found."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Check and generate secure random passwords for placeholders
|
||||
if grep -q "CHANGE_ME_TO_STRONG_PASSWORD" "$ENV_FILE"; then
|
||||
echo "🔐 Generating secure random passwords in $ENV_FILE..."
|
||||
PG_PASS=$(openssl rand -hex 16)
|
||||
NEO_PASS=$(openssl rand -hex 16)
|
||||
# Use standard sed compatible with Linux
|
||||
sed -i "s/POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/POSTGRES_PASSWORD=$PG_PASS/g" "$ENV_FILE"
|
||||
sed -i "s/NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/NEO4J_PASSWORD=$NEO_PASS/g" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if grep -q "CHANGE_ME_TO_SECURE_ADMIN_PASSWORD" "$ENV_FILE"; then
|
||||
echo "🔐 Generating secure admin seed password in $ENV_FILE..."
|
||||
ADMIN_PASS=$(openssl rand -hex 16)
|
||||
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if grep -q "^QDRANT_API_KEY=$" "$ENV_FILE" || grep -q "^QDRANT_API_KEY=[[:space:]]*$" "$ENV_FILE"; then
|
||||
echo "🔐 Generating secure random Qdrant API key in $ENV_FILE..."
|
||||
QD_KEY=$(openssl rand -hex 16)
|
||||
sed -i "s/^QDRANT_API_KEY=.*/QDRANT_API_KEY=$QD_KEY/g" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# Load staging variables for local execution context (needed for ports/migrations)
|
||||
# Clean up carriage returns just in case
|
||||
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||
POSTGRES_PASSWORD=$(grep "^POSTGRES_PASSWORD=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||
POSTGRES_DB=$(grep "^POSTGRES_DB=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||
POSTGRES_PORT=$(grep "^POSTGRES_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||
WEB_PORT=$(grep "^WEB_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||
QDRANT_HTTP_PORT=$(grep "^QDRANT_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||
NEO4J_HTTP_PORT=$(grep "^NEO4J_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||
|
||||
# Fallbacks in case env parsing is empty
|
||||
POSTGRES_PORT=${POSTGRES_PORT:-5438}
|
||||
WEB_PORT=${WEB_PORT:-5080}
|
||||
|
||||
# 3. Stop any conflicting Docker Compose environments
|
||||
if [ "$NEXUS_ONLY" = true ]; then
|
||||
echo "🧹 Stopping and removing only the web (nexus) container..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
||||
else
|
||||
echo "🧹 Stopping existing containers..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
||||
docker compose down --remove-orphans 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 4. Build and start containers
|
||||
if [ "$NEXUS_ONLY" = true ]; then
|
||||
echo "🚀 Building and restarting only the web (nexus) container..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build web
|
||||
else
|
||||
echo "🚀 Building and starting staging containers..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
|
||||
fi
|
||||
|
||||
# 5. Wait for Database to be healthy
|
||||
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
|
||||
MAX_ATTEMPTS=30
|
||||
attempt=0
|
||||
until [ "$(docker inspect --format='{{json .State.Health.Status}}' nexus-db-stage 2>/dev/null)" == "\"healthy\"" ]; do
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $MAX_ATTEMPTS ]; then
|
||||
echo "❌ Timeout: Database container never became healthy."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs db
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "✅ Database is healthy!"
|
||||
|
||||
# 6. Apply Entity Framework migrations
|
||||
echo "🔄 Applying EF Core migrations to staging database on port $POSTGRES_PORT..."
|
||||
export ConnectionStrings__PostgresConnection="Host=127.0.0.1;Port=$POSTGRES_PORT;Database=$POSTGRES_DB;Username=$POSTGRES_USER;Password=$POSTGRES_PASSWORD"
|
||||
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
|
||||
|
||||
# 7. Wait for Web Application to respond
|
||||
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT/health..."
|
||||
MAX_WEB_ATTEMPTS=30
|
||||
web_attempt=0
|
||||
until curl -s -f "http://localhost:$WEB_PORT/health" >/dev/null; do
|
||||
sleep 2
|
||||
web_attempt=$((web_attempt + 1))
|
||||
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
|
||||
echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT/health, but let's check logs..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "🎉 Staging environment is ready!"
|
||||
echo "--------------------------------------------------------"
|
||||
echo "🌐 Web Application: http://localhost:$WEB_PORT"
|
||||
echo "🗄️ PostgreSQL Port: $POSTGRES_PORT"
|
||||
echo "🔎 Neo4j Console: http://localhost:$NEO4J_HTTP_PORT"
|
||||
echo "📊 Qdrant Service: http://localhost:$QDRANT_HTTP_PORT"
|
||||
echo "--------------------------------------------------------"
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to user library ownership details, decoupling the relational database
|
||||
/// structures from vector search and intelligence query operations.
|
||||
/// </summary>
|
||||
public interface IUserLibraryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a list of book IDs that are owned by or uploaded for the specified user.
|
||||
/// </summary>
|
||||
Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a dictionary mapping book IDs to their titles.
|
||||
/// </summary>
|
||||
Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Decoupled database store to retrieve active user reading states and chapter content.
|
||||
/// </summary>
|
||||
public interface IUserReadingStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID.
|
||||
/// </summary>
|
||||
Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the text content of a specific chapter/page by its ID.
|
||||
/// </summary>
|
||||
Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of text retrieved from the semantic vector database.
|
||||
/// </summary>
|
||||
public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = "");
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer.
|
||||
/// </summary>
|
||||
public interface IVectorSearchStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches within a whitelist of owned book IDs for the best semantic matches.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
using NexusReader.Application.Queries.Intelligence;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
@@ -13,6 +14,7 @@ public interface IKnowledgeService
|
||||
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
||||
Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IUserPreferenceStore
|
||||
{
|
||||
Task<Result> SaveThemePreferenceAsync(ThemeMode mode);
|
||||
Task<Result<ThemeMode>> GetThemePreferenceAsync();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.Commands.User;
|
||||
|
||||
public record UpdateThemeCommand(string UserId, ThemeMode Mode) : ICommand;
|
||||
@@ -0,0 +1,41 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Collections.Generic;
|
||||
using NexusReader.Application.Queries.Graph;
|
||||
using NexusReader.Application.Queries.Intelligence;
|
||||
|
||||
namespace NexusReader.Application.Common;
|
||||
|
||||
@@ -9,6 +11,28 @@ namespace NexusReader.Application.Common;
|
||||
[JsonSerializable(typeof(GraphDataDto))]
|
||||
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
||||
[JsonSerializable(typeof(IntelligenceResponse))]
|
||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
||||
[JsonSerializable(typeof(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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace NexusReader.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Configurations for the monetization engine, controlling the thresholds at which
|
||||
/// search queries trigger paywalls.
|
||||
/// </summary>
|
||||
public class RagMonetizationOptions
|
||||
{
|
||||
public const string SectionName = "RagMonetization";
|
||||
|
||||
/// <summary>
|
||||
/// The baseline score threshold above which global content might trigger a paywall if there is no local content.
|
||||
/// Default: 0.45.
|
||||
/// </summary>
|
||||
public double BaselineThreshold { get; set; } = 0.45;
|
||||
|
||||
/// <summary>
|
||||
/// The similarity gap (Delta) required between global and local content to trigger an upgrade paywall.
|
||||
/// Default: 0.15.
|
||||
/// </summary>
|
||||
public double DeltaThreshold { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// The absolute score required from global content to trigger an upgrade paywall.
|
||||
/// Default: 0.70.
|
||||
/// </summary>
|
||||
public double UpgradeThreshold { get; set; } = 0.70;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NexusReader.Application.DTOs.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry metrics for the Creator Dashboard.
|
||||
/// </summary>
|
||||
public record DashboardMetricsDto(
|
||||
int TotalReads,
|
||||
double AvgReadTimeMinutes,
|
||||
int ActiveReaders,
|
||||
decimal GrossRevenue
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight revision details for the Creator Dashboard.
|
||||
/// </summary>
|
||||
public record CreatorBookRevisionDto(
|
||||
Guid Id,
|
||||
string VersionString,
|
||||
bool IsPublished,
|
||||
DateTime CreatedAt,
|
||||
DateTime? PublishedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight book publication details for the Creator Dashboard.
|
||||
/// </summary>
|
||||
public record CreatorBookDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
int WordCount,
|
||||
int AggregatedReads,
|
||||
Guid? FirstChapterId,
|
||||
CreatorBookRevisionDto? LivePublishedRevision,
|
||||
CreatorBookRevisionDto? CurrentDraftRevision
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Root data envelope for Creator Dashboard loading.
|
||||
/// </summary>
|
||||
public record CreatorDashboardDataDto(
|
||||
DashboardMetricsDto Metrics,
|
||||
List<CreatorBookDto> Books
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating a new Book.
|
||||
/// </summary>
|
||||
public record CreateBookRequestDto(
|
||||
string Title,
|
||||
string? Description
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for creating a new Book.
|
||||
/// </summary>
|
||||
public record CreateBookResponseDto(
|
||||
Guid BookId
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
@@ -0,0 +1,5 @@
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.DTOs.User;
|
||||
|
||||
public record UpdateThemeRequest(ThemeMode Mode);
|
||||
@@ -1,4 +1,5 @@
|
||||
using NexusReader.Application.Constants;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.DTOs.User;
|
||||
|
||||
@@ -8,6 +9,7 @@ public record UserProfileDto
|
||||
public string UserId { get; init; } = string.Empty;
|
||||
public int AITokensUsed { get; init; }
|
||||
public Guid TenantId { get; init; }
|
||||
public ThemeMode ThemePreference { get; init; } = ThemeMode.System;
|
||||
|
||||
/// <summary>
|
||||
/// Relational data for the current subscription plan.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Features.Books.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to create a new Book, initialize its first Working Draft revision, and seed it with a default Introduction chapter.
|
||||
/// </summary>
|
||||
/// <param name="Title">The title of the new book.</param>
|
||||
/// <param name="Description">An optional description of the book.</param>
|
||||
/// <param name="UserId">The ID of the creator user.</param>
|
||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||
public record CreateBookCommand(
|
||||
string Title,
|
||||
string? Description,
|
||||
string UserId,
|
||||
string TenantId
|
||||
) : ICommand<Guid>;
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Features.Books.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR handler for creating a Book, creating its initial Working Draft revision,
|
||||
/// and seeding a default first chapter ("Introduction") in an atomic database transaction.
|
||||
/// </summary>
|
||||
public class CreateBookCommandHandler : ICommandHandler<CreateBookCommand, Guid>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public CreateBookCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(CreateBookCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return Result.Fail<Guid>(new Error("Book title is required."));
|
||||
}
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Instantiate the Book record mapping Title, UserId, and TenantId
|
||||
var book = new Book
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = request.Title.Trim(),
|
||||
UserId = request.UserId,
|
||||
TenantId = request.TenantId,
|
||||
CurrentDraftRevisionId = null,
|
||||
LivePublishedRevisionId = null
|
||||
};
|
||||
|
||||
dbContext.Books.Add(book);
|
||||
|
||||
// 2. Instantiate the initial BookRevision designated as "Working Draft"
|
||||
var draftRevision = new BookRevision
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BookId = book.Id,
|
||||
VersionString = "Working Draft",
|
||||
IsPublished = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.BookRevisions.Add(draftRevision);
|
||||
|
||||
// 3. Automatically instantiate and append a default first Chapter to this new revision
|
||||
var introChapter = new Chapter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BookRevisionId = draftRevision.Id,
|
||||
Title = "Introduction",
|
||||
MarkdownContent = "# Introduction\nStart writing here...",
|
||||
SortOrder = 1
|
||||
};
|
||||
|
||||
dbContext.Chapters.Add(introChapter);
|
||||
|
||||
// Save first to generate DB references
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. Inject the newly instantiated draft revision ID back into Book.CurrentDraftRevisionId
|
||||
book.CurrentDraftRevisionId = draftRevision.Id;
|
||||
|
||||
// Save the updated Book link
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Commit transaction
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(book.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception rollbackEx)
|
||||
{
|
||||
Console.WriteLine($"[CreateBook] Transaction rollback failed: {rollbackEx.Message}");
|
||||
}
|
||||
|
||||
return Result.Fail<Guid>(new Error($"Failed to create book: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Features.Books.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to publish a new frozen version of a Book, and create a new Working Draft.
|
||||
/// </summary>
|
||||
/// <param name="BookId">The unique identifier of the Book to publish.</param>
|
||||
/// <param name="CustomVersionString">The custom version string to apply (e.g. "v1.0").</param>
|
||||
/// <param name="UserId">The ID of the user requesting the action.</param>
|
||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||
public record PublishBookVersionCommand(Guid BookId, string CustomVersionString, string UserId, string TenantId) : ICommand;
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Domain.Exceptions;
|
||||
|
||||
namespace NexusReader.Application.Features.Books.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR handler for publishing a Book version and setting up the next Working Draft.
|
||||
/// </summary>
|
||||
public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersionCommand>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public PublishBookVersionCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(PublishBookVersionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Fetch the Book including its CurrentDraftRevision and all associated Chapters,
|
||||
// enforcing that the book belongs to the requested TenantId and UserId to prevent cross-tenant data leaks.
|
||||
var book = await dbContext.Books
|
||||
.Include(b => b.CurrentDraftRevision)
|
||||
.ThenInclude(r => r!.Chapters)
|
||||
.FirstOrDefaultAsync(
|
||||
b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
if (book == null)
|
||||
{
|
||||
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
|
||||
}
|
||||
|
||||
var oldDraftRevision = book.CurrentDraftRevision;
|
||||
if (oldDraftRevision == null)
|
||||
{
|
||||
return Result.Fail(new Error("The book does not have an active draft revision to publish."));
|
||||
}
|
||||
|
||||
// Start ACID transaction
|
||||
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// 1. Update the current draft revision: Set IsPublished = true, PublishedAt = now, VersionString = custom
|
||||
oldDraftRevision.IsPublished = true;
|
||||
oldDraftRevision.PublishedAt = DateTime.UtcNow;
|
||||
oldDraftRevision.VersionString = request.CustomVersionString;
|
||||
|
||||
// 2. Point the Book.LivePublishedRevisionId to this newly frozen revision ID
|
||||
book.LivePublishedRevisionId = oldDraftRevision.Id;
|
||||
|
||||
// 3. Execute Deep Snapshot: Instantiate a brand new BookRevision representing the next "Working Draft"
|
||||
var newDraftRevision = new BookRevision
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BookId = book.Id,
|
||||
VersionString = "Working Draft",
|
||||
IsPublished = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.BookRevisions.Add(newDraftRevision);
|
||||
|
||||
// Replicate/clone chapters into new Chapter objects associated with the new draft revision.
|
||||
// Reset identities by explicitly instantiating completely new Chapter objects with Guid.NewGuid().
|
||||
foreach (var oldChapter in oldDraftRevision.Chapters)
|
||||
{
|
||||
var newChapter = new Chapter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BookRevisionId = newDraftRevision.Id,
|
||||
Title = oldChapter.Title,
|
||||
MarkdownContent = oldChapter.MarkdownContent,
|
||||
SortOrder = oldChapter.SortOrder
|
||||
};
|
||||
dbContext.Chapters.Add(newChapter);
|
||||
}
|
||||
|
||||
// 4. Assign the new draft revision ID to Book.CurrentDraftRevisionId
|
||||
book.CurrentDraftRevisionId = newDraftRevision.Id;
|
||||
|
||||
// Save changes and commit transaction
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception rollbackEx)
|
||||
{
|
||||
Console.WriteLine($"[PublishBookVersion] Transaction rollback failed: {rollbackEx.Message}");
|
||||
}
|
||||
|
||||
return Result.Fail(new Error($"Failed to publish book version: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.DTOs.Creator;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Exceptions;
|
||||
|
||||
namespace NexusReader.Application.Queries.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// Query to load all revisions for a specific Book, checking multi-tenant ownership boundaries.
|
||||
/// </summary>
|
||||
/// <param name="BookId">The unique identifier of the target Book.</param>
|
||||
/// <param name="UserId">The ID of the creator requesting revision data.</param>
|
||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||
public record GetBookRevisionsQuery(Guid BookId, string UserId, string TenantId) : IQuery<List<CreatorBookRevisionDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// Handler that lists past revisions of a Book, verifying ownership to prevent cross-tenant leakages.
|
||||
/// </summary>
|
||||
public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery, List<CreatorBookRevisionDto>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public GetBookRevisionsQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<FluentResults.Result<List<CreatorBookRevisionDto>>> Handle(GetBookRevisionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Verify the book exists and belongs to this tenant/user to prevent cross-tenant data leaks
|
||||
var bookExists = await dbContext.Books
|
||||
.AnyAsync(b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId, cancellationToken);
|
||||
|
||||
if (!bookExists)
|
||||
{
|
||||
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
|
||||
}
|
||||
|
||||
// Fetch all revisions sorted chronologically
|
||||
var revisions = await dbContext.BookRevisions
|
||||
.AsNoTracking()
|
||||
.Where(r => r.BookId == request.BookId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Select(r => new CreatorBookRevisionDto(
|
||||
r.Id,
|
||||
r.VersionString,
|
||||
r.IsPublished,
|
||||
r.CreatedAt,
|
||||
r.PublishedAt
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return FluentResults.Result.Ok(revisions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.DTOs.Creator;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Queries.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// Query to load aggregated Creator Dashboard telemetry metrics and book listings.
|
||||
/// </summary>
|
||||
/// <param name="UserId">The ID of the creator requesting dashboard data.</param>
|
||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||
public record GetCreatorDashboardDataQuery(string UserId, string TenantId) : IQuery<CreatorDashboardDataDto>;
|
||||
|
||||
/// <summary>
|
||||
/// Handler that executes projection-only LINQ queries to aggregate metrics and compute word counts
|
||||
/// without loading raw chapter content into memory or tracking them in the EF Core Change Tracker.
|
||||
/// </summary>
|
||||
public class GetCreatorDashboardDataQueryHandler : IQueryHandler<GetCreatorDashboardDataQuery, CreatorDashboardDataDto>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public GetCreatorDashboardDataQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<FluentResults.Result<CreatorDashboardDataDto>> Handle(GetCreatorDashboardDataQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Execute projection-only LINQ query. The heavy MarkdownContent is projected only as integer lengths.
|
||||
var projectedBooks = await dbContext.Books
|
||||
.AsNoTracking()
|
||||
.Where(b => b.UserId == request.UserId && b.TenantId == request.TenantId)
|
||||
.Select(b => new
|
||||
{
|
||||
b.Id,
|
||||
b.Title,
|
||||
LivePublishedRevision = b.LivePublishedRevision == null ? null : new CreatorBookRevisionDto(
|
||||
b.LivePublishedRevision.Id,
|
||||
b.LivePublishedRevision.VersionString,
|
||||
b.LivePublishedRevision.IsPublished,
|
||||
b.LivePublishedRevision.CreatedAt,
|
||||
b.LivePublishedRevision.PublishedAt
|
||||
),
|
||||
CurrentDraftRevision = b.CurrentDraftRevision == null ? null : new CreatorBookRevisionDto(
|
||||
b.CurrentDraftRevision.Id,
|
||||
b.CurrentDraftRevision.VersionString,
|
||||
b.CurrentDraftRevision.IsPublished,
|
||||
b.CurrentDraftRevision.CreatedAt,
|
||||
b.CurrentDraftRevision.PublishedAt
|
||||
),
|
||||
FirstChapterId = b.CurrentDraftRevision == null
|
||||
? (Guid?)null
|
||||
: b.CurrentDraftRevision.Chapters.OrderBy(c => c.SortOrder).Select(c => c.Id).FirstOrDefault(),
|
||||
ChapterContentLengths = b.CurrentDraftRevision == null
|
||||
? new List<int>()
|
||||
: b.CurrentDraftRevision.Chapters.Select(c => c.MarkdownContent.Length).ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var booksList = new List<CreatorBookDto>();
|
||||
int totalReads = 0;
|
||||
int totalWords = 0;
|
||||
|
||||
foreach (var pBook in projectedBooks)
|
||||
{
|
||||
// Estimate word count (approx. 6 characters per word as a database-friendly standard length)
|
||||
int wordCount = pBook.ChapterContentLengths.Sum(len => len / 6);
|
||||
totalWords += wordCount;
|
||||
|
||||
// Generate deterministic simulated telemetry metrics scoped to this Book
|
||||
int bookReads = Math.Abs(pBook.Id.GetHashCode() % 1000) + 120;
|
||||
totalReads += bookReads;
|
||||
|
||||
var bookDto = new CreatorBookDto(
|
||||
pBook.Id,
|
||||
pBook.Title,
|
||||
wordCount,
|
||||
bookReads,
|
||||
pBook.FirstChapterId,
|
||||
pBook.LivePublishedRevision,
|
||||
pBook.CurrentDraftRevision
|
||||
);
|
||||
|
||||
booksList.Add(bookDto);
|
||||
}
|
||||
|
||||
// Calculate aggregate dashboard metrics based on projected stats
|
||||
int activeReaders = projectedBooks.Count == 0 ? 0 : Math.Abs(request.UserId.GetHashCode() % 15) + 3;
|
||||
decimal grossRevenue = totalReads * 1.49m;
|
||||
double avgReadTime = projectedBooks.Count == 0 ? 0 : Math.Round(totalWords / 250.0, 1); // standard 250 words per minute reading speed
|
||||
|
||||
var metrics = new DashboardMetricsDto(
|
||||
totalReads,
|
||||
avgReadTime,
|
||||
activeReaders,
|
||||
grossRevenue
|
||||
);
|
||||
|
||||
return FluentResults.Result.Ok(new CreatorDashboardDataDto(metrics, booksList));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Common;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
|
||||
namespace NexusReader.Application.Queries.Intelligence;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR query to request global intelligence hybrid Q&A context.
|
||||
/// </summary>
|
||||
public record GetGlobalIntelligenceQuery(string QueryText, string UserId, string TenantId = "global")
|
||||
: IRequest<Result<IntelligenceResponse>>;
|
||||
|
||||
/// <summary>
|
||||
/// Request schema for global hybrid search queries.
|
||||
/// </summary>
|
||||
public record GetGlobalIntelligenceRequest(string QueryText);
|
||||
|
||||
/// <summary>
|
||||
/// Response schema returning generated AI text, paywall status, and locked publishing details.
|
||||
/// </summary>
|
||||
public record IntelligenceResponse(
|
||||
string ResponseText,
|
||||
bool HasPaywall,
|
||||
Guid? LockedBookId,
|
||||
string? LockedBookTitle,
|
||||
List<CitationDto>? Citations = null);
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetGlobalIntelligenceQuery"/> by performing local/global dual searches,
|
||||
/// executing monetization rules, and invoking Chat AI with appropriate gating logic.
|
||||
/// </summary>
|
||||
public class GetGlobalIntelligenceQueryHandler : IRequestHandler<GetGlobalIntelligenceQuery, Result<IntelligenceResponse>>
|
||||
{
|
||||
private readonly IUserLibraryStore _userLibraryStore;
|
||||
private readonly IVectorSearchStore _vectorSearchStore;
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly RagMonetizationOptions _options;
|
||||
|
||||
public GetGlobalIntelligenceQueryHandler(
|
||||
IUserLibraryStore userLibraryStore,
|
||||
IVectorSearchStore vectorSearchStore,
|
||||
IChatClient chatClient,
|
||||
IOptions<RagMonetizationOptions> options)
|
||||
{
|
||||
_userLibraryStore = userLibraryStore;
|
||||
_vectorSearchStore = vectorSearchStore;
|
||||
_chatClient = chatClient;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<Result<IntelligenceResponse>> Handle(GetGlobalIntelligenceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.QueryText))
|
||||
{
|
||||
return Result.Fail("Question cannot be empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step A: Fetch whitelisted BookIds
|
||||
var whitelistedBookIds = await _userLibraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
||||
|
||||
// Step B & C: Vector Dual-Search with Resilient Trapping
|
||||
List<VectorChunk> globalChunks = new();
|
||||
List<VectorChunk> localChunks = new();
|
||||
double globalScore = 0.0;
|
||||
double localScore = 0.0;
|
||||
|
||||
try
|
||||
{
|
||||
// Execute searches
|
||||
globalChunks = await _vectorSearchStore.SearchGlobalAsync(request.QueryText, request.TenantId, limit: 3, cancellationToken);
|
||||
globalScore = globalChunks.Any() ? Math.Max(0.0, globalChunks.Max(c => c.Score)) : 0.0;
|
||||
|
||||
if (whitelistedBookIds.Any())
|
||||
{
|
||||
localChunks = await _vectorSearchStore.SearchLocalAsync(request.QueryText, request.TenantId, whitelistedBookIds, limit: 3, cancellationToken);
|
||||
localScore = localChunks.Any() ? Math.Max(0.0, localChunks.Max(c => c.Score)) : 0.0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Resilient Error Trapping: transform connectivity anomalies into domain-friendly errors
|
||||
return Result.Fail(new Error("Serwer wyszukiwania semantycznego jest tymczasowo niedostępny. Spróbuj ponownie później.").CausedBy(ex));
|
||||
}
|
||||
|
||||
// Step D: Evaluate Monetization Thresholds
|
||||
bool triggerPaywall = false;
|
||||
|
||||
if (localScore == 0.0 && globalScore > _options.BaselineThreshold)
|
||||
{
|
||||
triggerPaywall = true;
|
||||
}
|
||||
else if ((globalScore - localScore) > _options.DeltaThreshold && globalScore > _options.UpgradeThreshold)
|
||||
{
|
||||
triggerPaywall = true;
|
||||
}
|
||||
|
||||
var chosenChunks = triggerPaywall ? globalChunks : localChunks;
|
||||
|
||||
// Fetch book titles for citations/paywall metadata
|
||||
var chunkEbookIds = chosenChunks
|
||||
.Where(c => Guid.TryParse(c.EbookId, out _))
|
||||
.Select(c => Guid.Parse(c.EbookId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var bookTitles = await _userLibraryStore.GetBookTitlesAsync(chunkEbookIds, cancellationToken);
|
||||
|
||||
// Step E: Identify locked book if paywall triggered
|
||||
Guid? lockedBookId = null;
|
||||
string? lockedBookTitle = null;
|
||||
|
||||
if (triggerPaywall && globalChunks.Any())
|
||||
{
|
||||
var topGlobalChunk = globalChunks.OrderByDescending(c => c.Score).First();
|
||||
if (Guid.TryParse(topGlobalChunk.EbookId, out var parsedLockedId))
|
||||
{
|
||||
lockedBookId = parsedLockedId;
|
||||
bookTitles.TryGetValue(parsedLockedId, out lockedBookTitle);
|
||||
if (string.IsNullOrEmpty(lockedBookTitle))
|
||||
{
|
||||
lockedBookTitle = "Nieznana książka";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format context blocks for LLM
|
||||
var relatedContexts = new List<string>();
|
||||
foreach (var chunk in chosenChunks)
|
||||
{
|
||||
var sourceId = chunk.EbookId;
|
||||
relatedContexts.Add($"[Source ID: {sourceId}] {chunk.Content}");
|
||||
}
|
||||
var contextBlocksText = string.Join("\n\n", relatedContexts);
|
||||
|
||||
// Build LLM prompts
|
||||
var systemPrompt = "You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.\n" +
|
||||
"Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.\n" +
|
||||
"If the context does not contain the answer, say: 'I cannot answer this based on the provided book context.'";
|
||||
|
||||
if (triggerPaywall)
|
||||
{
|
||||
var localScorePercent = (int)Math.Round(localScore * 100);
|
||||
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
||||
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
||||
|
||||
systemPrompt += $"\n\nCRITICAL: You are operating in TEASER mode. The user does not own the source document named '{resolvedTitle}'. You are strictly allowed to provide only a 1-sentence foundational definition or answer based on the context to prove the system knows the solution. DO NOT output code blocks, implementation details, or bullet points. You must immediately terminate your response with this exact token format: [PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}].";
|
||||
}
|
||||
|
||||
var messages = new List<Microsoft.Extensions.AI.ChatMessage>
|
||||
{
|
||||
new(Microsoft.Extensions.AI.ChatRole.System, systemPrompt),
|
||||
new(Microsoft.Extensions.AI.ChatRole.User, $"Context:\n{contextBlocksText}\n\nQuestion: {request.QueryText}")
|
||||
};
|
||||
|
||||
var chatOptions = new ChatOptions
|
||||
{
|
||||
Temperature = 0.0f,
|
||||
MaxOutputTokens = 1000
|
||||
};
|
||||
|
||||
var chatResponse = await _chatClient.GetResponseAsync(messages, chatOptions, cancellationToken);
|
||||
var responseText = chatResponse.Text?.Trim() ?? string.Empty;
|
||||
|
||||
// Ensure the paywall token is appended if LLM misses it in teaser mode
|
||||
if (triggerPaywall)
|
||||
{
|
||||
var localScorePercent = (int)Math.Round(localScore * 100);
|
||||
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
||||
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
||||
var paywallToken = $"[PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}]";
|
||||
|
||||
if (!responseText.Contains("[PAYWALL_TRIGGER:"))
|
||||
{
|
||||
responseText = responseText.Trim() + " " + paywallToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Build citations list
|
||||
var citations = new List<CitationDto>();
|
||||
foreach (var chunk in chosenChunks)
|
||||
{
|
||||
var sourceBookName = "Unknown";
|
||||
if (Guid.TryParse(chunk.EbookId, out var parsedId) && bookTitles.TryGetValue(parsedId, out var title))
|
||||
{
|
||||
sourceBookName = title;
|
||||
}
|
||||
|
||||
citations.Add(new CitationDto
|
||||
{
|
||||
CitationId = chunk.EbookId,
|
||||
Snippet = chunk.Content,
|
||||
SourceBook = sourceBookName,
|
||||
Author = null,
|
||||
PageNumber = null
|
||||
});
|
||||
}
|
||||
|
||||
return Result.Ok(new IntelligenceResponse(
|
||||
ResponseText: responseText,
|
||||
HasPaywall: triggerPaywall,
|
||||
LockedBookId: lockedBookId,
|
||||
LockedBookTitle: lockedBookTitle,
|
||||
Citations: citations
|
||||
));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Nieoczekiwany błąd serwera podczas przetwarzania zapytania.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Queries.Recommendations;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR query to fetch contextual recommendations based on the user's active reading state.
|
||||
/// </summary>
|
||||
public record GetContextualRecommendationsQuery(string UserId)
|
||||
: IRequest<Result<ContextualRecommendationResponse>>;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO containing contextual recommendations.
|
||||
/// </summary>
|
||||
public record ContextualRecommendationResponse(List<RecommendationDto> Recommendations);
|
||||
|
||||
/// <summary>
|
||||
/// Individual contextual recommendation details.
|
||||
/// </summary>
|
||||
public record RecommendationDto(
|
||||
string BookTitle,
|
||||
string ChapterTitle,
|
||||
int MatchPercentage,
|
||||
bool IsPremiumUpsell,
|
||||
Guid TargetBookId
|
||||
);
|
||||
@@ -27,6 +27,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
UserId = u.Id,
|
||||
AITokensUsed = u.AITokensUsed,
|
||||
TenantIdString = u.TenantId,
|
||||
ThemePreference = u.ThemePreference,
|
||||
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||
{
|
||||
Id = u.SubscriptionPlan.Id,
|
||||
@@ -106,6 +107,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
AverageQuizScore = averageQuizScore,
|
||||
DisplayName = userRaw.DisplayName,
|
||||
BooksReadCount = userRaw.BooksReadCount,
|
||||
ThemePreference = userRaw.ThemePreference,
|
||||
ConceptsMappedCount = conceptsMappedCount,
|
||||
LastReadBook = userRaw.LastReadBook,
|
||||
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
|
||||
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
// <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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+865
@@ -0,0 +1,865 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260611183927_AddBookVersioningSupport")]
|
||||
partial class AddBookVersioningSupport
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CurrentDraftRevisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("LivePublishedRevisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CurrentDraftRevisionId");
|
||||
|
||||
b.HasIndex("LivePublishedRevisionId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("BookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("PublishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VersionString")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.ToTable("BookRevisions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("BookRevisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MarkdownContent")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookRevisionId");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("EbookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EbookId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("ThemePreference")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
||||
.WithMany()
|
||||
.HasForeignKey("CurrentDraftRevisionId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
||||
.WithMany()
|
||||
.HasForeignKey("LivePublishedRevisionId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CurrentDraftRevision");
|
||||
|
||||
b.Navigation("LivePublishedRevision");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
||||
.WithMany("Revisions")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("BookRevisionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("BookRevision");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
||||
.WithMany()
|
||||
.HasForeignKey("EbookId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Ebook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||
{
|
||||
b.Navigation("Revisions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBookVersioningSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BookRevisions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
BookId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
VersionString = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
IsPublished = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
PublishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BookRevisions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Books",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
UserId = table.Column<string>(type: "text", nullable: false),
|
||||
CurrentDraftRevisionId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
LivePublishedRevisionId = table.Column<Guid>(type: "uuid", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Books", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Books_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Books_BookRevisions_CurrentDraftRevisionId",
|
||||
column: x => x.CurrentDraftRevisionId,
|
||||
principalTable: "BookRevisions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Books_BookRevisions_LivePublishedRevisionId",
|
||||
column: x => x.LivePublishedRevisionId,
|
||||
principalTable: "BookRevisions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Chapters",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
BookRevisionId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
MarkdownContent = table.Column<string>(type: "text", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Chapters", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Chapters_BookRevisions_BookRevisionId",
|
||||
column: x => x.BookRevisionId,
|
||||
principalTable: "BookRevisions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookRevisions_BookId",
|
||||
table: "BookRevisions",
|
||||
column: "BookId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Books_CurrentDraftRevisionId",
|
||||
table: "Books",
|
||||
column: "CurrentDraftRevisionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Books_LivePublishedRevisionId",
|
||||
table: "Books",
|
||||
column: "LivePublishedRevisionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Books_TenantId",
|
||||
table: "Books",
|
||||
column: "TenantId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Books_UserId",
|
||||
table: "Books",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Chapters_BookRevisionId",
|
||||
table: "Chapters",
|
||||
column: "BookRevisionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_BookRevisions_Books_BookId",
|
||||
table: "BookRevisions",
|
||||
column: "BookId",
|
||||
principalTable: "Books",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_BookRevisions_Books_BookId",
|
||||
table: "BookRevisions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Chapters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Books");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BookRevisions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@@ -21,7 +20,6 @@ namespace NexusReader.Data.Migrations
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
@@ -174,6 +172,103 @@ namespace NexusReader.Data.Migrations
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CurrentDraftRevisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("LivePublishedRevisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CurrentDraftRevisionId");
|
||||
|
||||
b.HasIndex("LivePublishedRevisionId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("BookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("PublishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VersionString")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.ToTable("BookRevisions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("BookRevisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MarkdownContent")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookRevisionId");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -264,9 +359,6 @@ namespace NexusReader.Data.Migrations
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
@@ -388,6 +480,11 @@ namespace NexusReader.Data.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("ThemePreference")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -480,9 +577,6 @@ namespace NexusReader.Data.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
@@ -617,6 +711,53 @@ namespace NexusReader.Data.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
||||
.WithMany()
|
||||
.HasForeignKey("CurrentDraftRevisionId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
||||
.WithMany()
|
||||
.HasForeignKey("LivePublishedRevisionId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CurrentDraftRevision");
|
||||
|
||||
b.Navigation("LivePublishedRevision");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
||||
.WithMany("Revisions")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("BookRevisionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("BookRevision");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
@@ -692,6 +833,16 @@ namespace NexusReader.Data.Migrations
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||
{
|
||||
b.Navigation("Revisions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
@@ -24,6 +25,9 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||
public DbSet<Author> Authors => Set<Author>();
|
||||
public DbSet<Book> Books => Set<Book>();
|
||||
public DbSet<BookRevision> BookRevisions => Set<BookRevision>();
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -43,6 +47,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
||||
entity.Property(u => u.SubscriptionPlanId)
|
||||
.HasDefaultValue(1);
|
||||
|
||||
entity.Property(u => u.ThemePreference)
|
||||
.HasConversion<int>()
|
||||
.HasDefaultValue(ThemeMode.System);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||
@@ -109,6 +117,48 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
entity.HasIndex(e => e.TenantId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Book>(entity =>
|
||||
{
|
||||
entity.HasKey(b => b.Id);
|
||||
entity.HasIndex(b => b.TenantId);
|
||||
entity.HasIndex(b => b.UserId);
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(b => b.Revisions)
|
||||
.WithOne(r => r.Book)
|
||||
.HasForeignKey(r => r.BookId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(b => b.CurrentDraftRevision)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.CurrentDraftRevisionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasOne(b => b.LivePublishedRevision)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.LivePublishedRevisionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<BookRevision>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
|
||||
entity.HasMany(r => r.Chapters)
|
||||
.WithOne(c => c.BookRevision)
|
||||
.HasForeignKey(c => c.BookRevisionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Chapter>(entity =>
|
||||
{
|
||||
entity.HasKey(c => c.Id);
|
||||
});
|
||||
|
||||
// Seed Subscription Plans with deterministic IDs
|
||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
||||
|
||||
@@ -136,6 +136,72 @@ public static class DbInitializer
|
||||
{
|
||||
Console.WriteLine("[Seeder] Admin user already exists.");
|
||||
}
|
||||
|
||||
// Seed Sample Authored Book for Creator Dashboard
|
||||
var activeAdmin = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
|
||||
if (activeAdmin != null)
|
||||
{
|
||||
if (!dbContext.Books.Any(b => b.UserId == activeAdmin.Id))
|
||||
{
|
||||
var sampleBookId = Guid.NewGuid();
|
||||
var sampleBook = new Book
|
||||
{
|
||||
Id = sampleBookId,
|
||||
Title = "Przewodnik po platformie Nexus",
|
||||
UserId = activeAdmin.Id,
|
||||
TenantId = activeAdmin.TenantId ?? "global"
|
||||
};
|
||||
dbContext.Books.Add(sampleBook);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var sampleRevisionId = Guid.NewGuid();
|
||||
var sampleRevision = new BookRevision
|
||||
{
|
||||
Id = sampleRevisionId,
|
||||
BookId = sampleBookId,
|
||||
VersionString = "Working Draft",
|
||||
IsPublished = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
dbContext.BookRevisions.Add(sampleRevision);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var sampleChapter1 = new Chapter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BookRevisionId = sampleRevisionId,
|
||||
Title = "Rozdział 1: Wprowadzenie do Zen Mode",
|
||||
MarkdownContent = @"# Zen Mode Editor
|
||||
|
||||
Welcome to your dedicated workspace. This premium panel supports Notion-like WYSIWYG editing.
|
||||
|
||||
## Features:
|
||||
- **Zero Distraction**: Simple elevation and border framing.
|
||||
- **GFM Tables**: Consistent cell padding and hover striping.
|
||||
- **Clean Code Blocks**: Pre-rendered base64 font-loaded code-preview blocks.",
|
||||
SortOrder = 1
|
||||
};
|
||||
|
||||
var sampleChapter2 = new Chapter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BookRevisionId = sampleRevisionId,
|
||||
Title = "Rozdział 2: Zabezpieczenia i XSS",
|
||||
MarkdownContent = @"# Security Overview
|
||||
|
||||
This module provides Magic Number image signature checking and HtmlSanitizer filters.",
|
||||
SortOrder = 2
|
||||
};
|
||||
|
||||
dbContext.Chapters.Add(sampleChapter1);
|
||||
dbContext.Chapters.Add(sampleChapter2);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
sampleBook.CurrentDraftRevisionId = sampleRevisionId;
|
||||
await dbContext.SaveChangesAsync();
|
||||
Console.WriteLine("[Seeder] Sample authored book and chapters seeded for admin.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Book metadata entry that references its decoupled revisions.
|
||||
/// </summary>
|
||||
public class Book
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string TenantId { get; set; } = "global";
|
||||
|
||||
[Required]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
[ForeignKey(nameof(UserId))]
|
||||
public virtual NexusUser? User { get; set; }
|
||||
|
||||
public Guid? CurrentDraftRevisionId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(CurrentDraftRevisionId))]
|
||||
public virtual BookRevision? CurrentDraftRevision { get; set; }
|
||||
|
||||
public Guid? LivePublishedRevisionId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(LivePublishedRevisionId))]
|
||||
public virtual BookRevision? LivePublishedRevision { get; set; }
|
||||
|
||||
public virtual ICollection<BookRevision> Revisions { get; set; } = new List<BookRevision>();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates a snapshot or draft version of a Book's chapters.
|
||||
/// </summary>
|
||||
public class BookRevision
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
public Guid BookId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(BookId))]
|
||||
public virtual Book Book { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string VersionString { get; set; } = "Working Draft";
|
||||
|
||||
public bool IsPublished { get; set; } = false;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
|
||||
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chapter belonging strictly to a specific BookRevision.
|
||||
/// </summary>
|
||||
public class Chapter
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
public Guid BookRevisionId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(BookRevisionId))]
|
||||
public virtual BookRevision BookRevision { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string MarkdownContent { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
@@ -65,4 +66,9 @@ public class NexusUser : IdentityUser
|
||||
/// Last read timestamp.
|
||||
/// </summary>
|
||||
public DateTime? LastReadAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User's visual theme preference.
|
||||
/// </summary>
|
||||
public ThemeMode ThemePreference { get; set; } = ThemeMode.System;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NexusReader.Domain.Enums;
|
||||
|
||||
public enum ThemeMode
|
||||
{
|
||||
System = 0,
|
||||
Dark = 1,
|
||||
LightSepia = 2
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace NexusReader.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Custom domain exception thrown when a Book cannot be found by its ID.
|
||||
/// </summary>
|
||||
public class BookNotFoundException : Exception
|
||||
{
|
||||
public BookNotFoundException(Guid bookId)
|
||||
: base($"Book with ID '{bookId}' was not found.")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.Common;
|
||||
using GeminiDotnet;
|
||||
using GeminiDotnet.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
@@ -54,7 +55,15 @@ public static class DependencyInjection
|
||||
|
||||
// Qdrant Client registration
|
||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
||||
var qdrantApiKey = configuration["Qdrant:ApiKey"];
|
||||
services.AddSingleton<QdrantClient>(sp =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(qdrantApiKey))
|
||||
{
|
||||
return new QdrantClient(new Uri(qdrantUrl), apiKey: qdrantApiKey);
|
||||
}
|
||||
return new QdrantClient(new Uri(qdrantUrl));
|
||||
});
|
||||
|
||||
// Neo4j Driver registration (supports optional authentication)
|
||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||
@@ -76,6 +85,8 @@ public static class DependencyInjection
|
||||
|
||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.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();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||
@@ -122,11 +133,16 @@ public static class DependencyInjection
|
||||
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
||||
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||
services.AddScoped<IStorageService, LocalStorageService>();
|
||||
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
|
||||
|
||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
||||
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
||||
services.AddScoped<IUserReadingStateStore, UserReadingStateStore>();
|
||||
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
||||
|
||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="Polly.Extensions.Http" />
|
||||
<PackageReference Include="HtmlSanitizer" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="Qdrant.Client" />
|
||||
<PackageReference Include="Stripe.net" />
|
||||
<PackageReference Include="VersOne.Epub" />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUserLibraryStore"/> using <see cref="AppDbContext"/>.
|
||||
/// </summary>
|
||||
internal sealed class UserLibraryStore : IUserLibraryStore
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public UserLibraryStore(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Ebooks
|
||||
.Where(e => e.UserId == userId)
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (bookIds == null || !bookIds.Any())
|
||||
{
|
||||
return new Dictionary<Guid, string>();
|
||||
}
|
||||
|
||||
return await _context.Ebooks
|
||||
.Where(e => bookIds.Contains(e.Id))
|
||||
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUserReadingStateStore"/>.
|
||||
/// </summary>
|
||||
internal sealed class UserReadingStateStore : IUserReadingStateStore
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public UserReadingStateStore(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var userState = await dbContext.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new
|
||||
{
|
||||
u.TenantId,
|
||||
u.LastReadPageId,
|
||||
LastReadBookId = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => (Guid?)e.Id).FirstOrDefault()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (userState == null)
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
return (userState.LastReadBookId, userState.LastReadPageId, userState.TenantId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
return await dbContext.KnowledgeUnits
|
||||
.Where(ku => ku.Id == chapterId)
|
||||
.Select(ku => ku.Content)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Qdrant.Client;
|
||||
using Qdrant.Client.Grpc;
|
||||
using Polly;
|
||||
using Polly.Registry;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Infrastructure implementation of <see cref="IVectorSearchStore"/> utilizing <see cref="QdrantClient"/>
|
||||
/// and <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> to execute semantic vector queries.
|
||||
/// </summary>
|
||||
internal sealed class VectorSearchStore : IVectorSearchStore
|
||||
{
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
private readonly ILogger<VectorSearchStore> _logger;
|
||||
|
||||
public VectorSearchStore(
|
||||
QdrantClient qdrantClient,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||
ResiliencePipelineProvider<string> pipelineProvider,
|
||||
ILogger<VectorSearchStore> logger)
|
||||
{
|
||||
_qdrantClient = qdrantClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||
var filter = BuildTenantFilter(tenantId);
|
||||
|
||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (whitelistedBookIds == null || !whitelistedBookIds.Any())
|
||||
{
|
||||
return new List<VectorChunk>();
|
||||
}
|
||||
|
||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||
var filter = BuildTenantFilter(tenantId);
|
||||
|
||||
var whitelistFilter = new Qdrant.Client.Grpc.Filter();
|
||||
foreach (var bookId in whitelistedBookIds)
|
||||
{
|
||||
whitelistFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "ebookId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = bookId.ToString() }
|
||||
}
|
||||
});
|
||||
}
|
||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = whitelistFilter });
|
||||
|
||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||
var filter = BuildTenantFilter(tenantId);
|
||||
|
||||
// Exclude current book
|
||||
filter.MustNot.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "ebookId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = excludeBookId.ToString() }
|
||||
}
|
||||
});
|
||||
|
||||
return await ExecuteSearchAsync(queryVector, filter, limit, 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 =>
|
||||
await _embeddingGenerator.GenerateAsync(
|
||||
new[] { text },
|
||||
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||
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();
|
||||
}
|
||||
|
||||
private Qdrant.Client.Grpc.Filter BuildTenantFilter(string tenantId)
|
||||
{
|
||||
var filter = new Qdrant.Client.Grpc.Filter();
|
||||
var tenantFilter = new Qdrant.Client.Grpc.Filter();
|
||||
|
||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
|
||||
}
|
||||
});
|
||||
|
||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
|
||||
}
|
||||
});
|
||||
|
||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = tenantFilter });
|
||||
return filter;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var response = await _qdrantClient.SearchAsync(
|
||||
collectionName: "knowledge_units",
|
||||
vector: queryVector,
|
||||
filter: filter,
|
||||
limit: (ulong)limit,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
sw.Stop();
|
||||
_logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds);
|
||||
|
||||
return response.Select(point =>
|
||||
{
|
||||
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
||||
var ebookId = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : string.Empty;
|
||||
var metadataJson = point.Payload.TryGetValue("metadataJson", out var mv) ? mv.StringValue : string.Empty;
|
||||
var bookTitle = point.Payload.TryGetValue("bookTitle", out var btv) ? btv.StringValue : string.Empty;
|
||||
var chapterTitle = point.Payload.TryGetValue("chapterTitle", out var ctv) ? ctv.StringValue : string.Empty;
|
||||
|
||||
return new VectorChunk(content, ebookId, point.Score, metadataJson, bookTitle, chapterTitle);
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[VectorSearchStore] Qdrant search execution failed.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName);
|
||||
await _qdrantClient.CreateCollectionAsync(
|
||||
collectionName: collectionName,
|
||||
vectorsConfig: new Qdrant.Client.Grpc.VectorParams
|
||||
{
|
||||
Size = 768,
|
||||
Distance = Distance.Cosine
|
||||
},
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate.
|
||||
_logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Queries.Recommendations;
|
||||
|
||||
namespace NexusReader.Infrastructure.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
|
||||
/// performing semantic search using <see cref="IVectorSearchStore"/> with book exclusion, and mapping upsells.
|
||||
/// </summary>
|
||||
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
|
||||
{
|
||||
private readonly IUserReadingStateStore _readingStateStore;
|
||||
private readonly IUserLibraryStore _libraryStore;
|
||||
private readonly IVectorSearchStore _vectorSearchStore;
|
||||
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
|
||||
/// </summary>
|
||||
public GetContextualRecommendationsQueryHandler(
|
||||
IUserReadingStateStore readingStateStore,
|
||||
IUserLibraryStore libraryStore,
|
||||
IVectorSearchStore vectorSearchStore,
|
||||
ILogger<GetContextualRecommendationsQueryHandler> logger)
|
||||
{
|
||||
_readingStateStore = readingStateStore;
|
||||
_libraryStore = libraryStore;
|
||||
_vectorSearchStore = vectorSearchStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.UserId))
|
||||
{
|
||||
return Result.Fail("UserId cannot be empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Discover active reading state
|
||||
var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken);
|
||||
if (ebookId == null)
|
||||
{
|
||||
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
|
||||
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
||||
}
|
||||
|
||||
// Step 2: Fetch specific content associated with active ChapterId
|
||||
string? chapterContent = null;
|
||||
if (!string.IsNullOrEmpty(chapterId))
|
||||
{
|
||||
chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken);
|
||||
}
|
||||
|
||||
// Guard: empty chapter content cannot produce a meaningful embedding
|
||||
if (string.IsNullOrWhiteSpace(chapterContent))
|
||||
{
|
||||
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
|
||||
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
||||
}
|
||||
|
||||
// Step 3: Perform similarity search using IVectorSearchStore
|
||||
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(
|
||||
chapterContent,
|
||||
resolvedTenantId,
|
||||
ebookId.Value,
|
||||
limit: 2,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
// Step 4: Process recommendations and cross-reference owned books
|
||||
var ownedBookIds = await _libraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
||||
var recommendations = new List<RecommendationDto>();
|
||||
|
||||
foreach (var point in searchResults)
|
||||
{
|
||||
var targetEbookIdStr = point.EbookId;
|
||||
if (!Guid.TryParse(targetEbookIdStr, out var targetEbookId))
|
||||
continue;
|
||||
|
||||
// Load bookTitle from point
|
||||
var bookTitle = point.BookTitle;
|
||||
if (string.IsNullOrEmpty(bookTitle))
|
||||
{
|
||||
bookTitle = "Nieznana książka";
|
||||
}
|
||||
|
||||
// Load chapterTitle from point or metadataJson
|
||||
var chapterTitle = point.ChapterTitle;
|
||||
if (string.IsNullOrEmpty(chapterTitle))
|
||||
{
|
||||
chapterTitle = "Wiedza z rozdziału";
|
||||
if (!string.IsNullOrEmpty(point.MetadataJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(point.MetadataJson);
|
||||
if (doc.RootElement.TryGetProperty("label", out var labelProp))
|
||||
{
|
||||
chapterTitle = labelProp.GetString() ?? chapterTitle;
|
||||
}
|
||||
}
|
||||
catch (JsonException jsonEx)
|
||||
{
|
||||
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isPremiumUpsell = !ownedBookIds.Contains(targetEbookId);
|
||||
var matchPercentage = (int)Math.Round(point.Score * 100);
|
||||
|
||||
recommendations.Add(new RecommendationDto(
|
||||
BookTitle: bookTitle,
|
||||
ChapterTitle: chapterTitle,
|
||||
MatchPercentage: matchPercentage,
|
||||
IsPremiumUpsell: isPremiumUpsell,
|
||||
TargetBookId: targetEbookId
|
||||
));
|
||||
}
|
||||
|
||||
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
|
||||
return Result.Ok(new ContextualRecommendationResponse(recommendations));
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public class BookStorageService : IBookStorageService
|
||||
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
||||
EnsureDirectoryExists(uploadsFolder);
|
||||
|
||||
fileName = SanitizeFileName(fileName);
|
||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
||||
|
||||
@@ -52,6 +53,7 @@ public class BookStorageService : IBookStorageService
|
||||
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
||||
EnsureDirectoryExists(coversFolder);
|
||||
|
||||
fileName = SanitizeFileName(fileName);
|
||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
||||
|
||||
@@ -63,6 +65,25 @@ public class BookStorageService : IBookStorageService
|
||||
return $"covers/{uniqueFileName}";
|
||||
}
|
||||
|
||||
private string SanitizeFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileName)) return fileName;
|
||||
|
||||
var sanitized = fileName
|
||||
.Replace('\u00A0', ' ')
|
||||
.Replace('\u2007', ' ')
|
||||
.Replace('\u200B', ' ')
|
||||
.Replace('\u202F', ' ');
|
||||
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
foreach (var c in invalidChars)
|
||||
{
|
||||
sanitized = sanitized.Replace(c, '_');
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private void EnsureDirectoryExists(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
|
||||
@@ -18,7 +18,7 @@ public class EpubReaderService : IEpubReader
|
||||
private readonly ILogger<EpubReaderService> _logger;
|
||||
private const int WordThreshold = 1000;
|
||||
|
||||
private static readonly Regex ImageTagRegex = new(@"<img\b(?<before>[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex ImageTagRegex = new(@"(?<before><img\b[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
@@ -27,6 +27,9 @@ public class EpubReaderService : IEpubReader
|
||||
private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex SvgImageTagRegex = new(@"<image\b(?<attrs>[^>]*?)>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex HrefAttributeRegex = new(@"\b(xlink:)?href=[""'](?<href>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex EmptyBlockRegex = new(@"^(</?(p|h[1-6]|ul|ol|li|blockquote|pre|div|span|br)\b[^>]*>| |\s)*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public EpubReaderService(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
@@ -102,7 +105,7 @@ public class EpubReaderService : IEpubReader
|
||||
foreach (var p in paragraphs)
|
||||
{
|
||||
var sanitizedContent = SanitizeParagraph(p);
|
||||
if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
|
||||
if (string.IsNullOrWhiteSpace(sanitizedContent) || EmptyBlockRegex.IsMatch(sanitizedContent)) continue;
|
||||
|
||||
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
||||
|
||||
@@ -236,7 +239,9 @@ public class EpubReaderService : IEpubReader
|
||||
{
|
||||
if (string.IsNullOrEmpty(html)) return html;
|
||||
|
||||
return ImageTagRegex.Replace(html, match =>
|
||||
var normalizedHtml = NormalizeSvgImageTags(html);
|
||||
|
||||
return ImageTagRegex.Replace(normalizedHtml, match =>
|
||||
{
|
||||
var rawSrc = match.Groups["src"].Value;
|
||||
|
||||
@@ -258,6 +263,31 @@ public class EpubReaderService : IEpubReader
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeSvgImageTags(string html)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html)) return html;
|
||||
|
||||
return SvgImageTagRegex.Replace(html, match =>
|
||||
{
|
||||
var attrs = match.Groups["attrs"].Value;
|
||||
|
||||
if (SrcAttributeRegex.IsMatch(attrs))
|
||||
{
|
||||
return $"<img {attrs}>";
|
||||
}
|
||||
|
||||
var hrefMatch = HrefAttributeRegex.Match(attrs);
|
||||
if (hrefMatch.Success)
|
||||
{
|
||||
var hrefVal = hrefMatch.Groups["href"].Value;
|
||||
var cleanedAttrs = HrefAttributeRegex.Replace(attrs, "");
|
||||
return $"<img src=\"{hrefVal}\" {cleanedAttrs}>";
|
||||
}
|
||||
|
||||
return match.Value;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveRelativePath(string basePath, string relativePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediatR;
|
||||
using NexusReader.Application.Queries.Intelligence;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
@@ -33,6 +35,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
private readonly ILogger<KnowledgeService> _logger;
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
private readonly IDriver _neo4jDriver;
|
||||
private readonly IMediator _mediator;
|
||||
private const string PromptVersion = "1.7";
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
||||
@@ -45,7 +48,8 @@ public class KnowledgeService : IKnowledgeService
|
||||
IOptions<AiSettings> settings,
|
||||
ILogger<KnowledgeService> logger,
|
||||
QdrantClient qdrantClient,
|
||||
IDriver neo4jDriver)
|
||||
IDriver neo4jDriver,
|
||||
IMediator mediator)
|
||||
{
|
||||
_chatClient = chatClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
@@ -55,6 +59,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
_logger = logger;
|
||||
_qdrantClient = qdrantClient;
|
||||
_neo4jDriver = neo4jDriver;
|
||||
_mediator = mediator;
|
||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||
@@ -334,6 +339,17 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
try
|
||||
{
|
||||
// Retrieve the book's title from the database using EF Core
|
||||
string bookTitle = "Nieznana książka";
|
||||
if (ebookId.HasValue)
|
||||
{
|
||||
var ebook = await dbContext.Ebooks.FindAsync(new object[] { ebookId.Value }, cancellationToken);
|
||||
if (ebook != null)
|
||||
{
|
||||
bookTitle = ebook.Title;
|
||||
}
|
||||
}
|
||||
|
||||
var contents = unitsToEmbed.Select(u => u.Content).ToList();
|
||||
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
@@ -350,6 +366,12 @@ public class KnowledgeService : IKnowledgeService
|
||||
var unitDto = unitsToEmbed[i];
|
||||
var vector = embeddings[i].Vector.ToArray();
|
||||
|
||||
string chapterTitle = "Wiedza z rozdziału";
|
||||
if (unitDto.Metadata != null && unitDto.Metadata.TryGetValue("label", out var labelVal) && labelVal is string labelStr)
|
||||
{
|
||||
chapterTitle = labelStr;
|
||||
}
|
||||
|
||||
var point = new PointStruct
|
||||
{
|
||||
Id = GetDeterministicGuid(unitDto.Id),
|
||||
@@ -360,6 +382,8 @@ public class KnowledgeService : IKnowledgeService
|
||||
["type"] = unitDto.Type ?? string.Empty,
|
||||
["tenantId"] = tenantId,
|
||||
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
||||
["bookTitle"] = bookTitle,
|
||||
["chapterTitle"] = chapterTitle,
|
||||
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
||||
}
|
||||
};
|
||||
@@ -1187,6 +1211,12 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _mediator.Send(new GetGlobalIntelligenceQuery(queryText, userId, tenantId), cancellationToken);
|
||||
}
|
||||
|
||||
private int EstimateTokenCount(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return 0;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using NexusReader.Application;
|
||||
using MediatR;
|
||||
using NexusReader.Maui.Infrastructure.Logging;
|
||||
using NexusReader.Maui.Infrastructure.Identity;
|
||||
using NexusReader.Maui.Services;
|
||||
|
||||
namespace NexusReader.Maui;
|
||||
|
||||
@@ -44,7 +45,7 @@ public static class MauiProgram
|
||||
|
||||
// Minimal Infrastructure
|
||||
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
||||
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
|
||||
builder.Services.AddSingleton<INativeStorageService, NexusReader.Infrastructure.Mobile.Services.MauiStorageService>();
|
||||
|
||||
// Minimal Identity (Safe Mode)
|
||||
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
||||
@@ -56,7 +57,7 @@ public static class MauiProgram
|
||||
builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
|
||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
{
|
||||
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000";
|
||||
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104";
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
|
||||
|
||||
@@ -67,13 +68,15 @@ public static class MauiProgram
|
||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||
builder.Services.AddSingleton(featureSettings);
|
||||
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<IUserPreferenceStore, MauiUserPreferenceStore>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"ApiSettings": {
|
||||
"BaseUrl": "https://localhost:5000"
|
||||
"BaseUrl": "http://localhost:5104"
|
||||
},
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
|
||||
@@ -7,6 +7,33 @@
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var themeMode = localStorage.getItem('theme-mode');
|
||||
var savedTheme = localStorage.getItem('theme');
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail silently
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -73,6 +100,7 @@
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="_content/NexusReader.UI.Shared/js/theme.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,106 +2,134 @@
|
||||
@switch (Name.ToLowerInvariant())
|
||||
{
|
||||
case "home":
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "map":
|
||||
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "share":
|
||||
case "share-2":
|
||||
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="18" cy="19" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="18" cy="19" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "help-circle":
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "robot":
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
|
||||
break;
|
||||
case "play":
|
||||
<path d="M8 5v14l11-7z" />
|
||||
<path d="M8 5v14l11-7z" />
|
||||
break;
|
||||
case "check":
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
break;
|
||||
case "search":
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
break;
|
||||
case "message-square":
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "diamond":
|
||||
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "layout":
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "book":
|
||||
case "book-open":
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "user":
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "settings":
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
|
||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "bookmark":
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "target":
|
||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="12" cy="12" r="2" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
break;
|
||||
case "trash":
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
|
||||
<polyline points="3 6 5 6 21 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "mail":
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "lock":
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "eye":
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" />
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "eye-off":
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="2" x2="22" y1="2" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "arrow-left":
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
<line x1="19" y1="12" x2="5" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline points="12 19 5 12 12 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "arrow-right":
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
<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" />
|
||||
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":
|
||||
<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;
|
||||
case "chevron-left":
|
||||
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "chevron-right":
|
||||
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "x":
|
||||
case "close":
|
||||
<line x1="18" y1="6" x2="6" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="18" y1="6" x2="6" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "sun":
|
||||
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
case "moon":
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
default:
|
||||
<!-- Fallback circle -->
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<!-- Fallback circle -->
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
break;
|
||||
}
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,559 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
.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,7 +1,9 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IQuizStateService QuizState
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject ILogger<AiAssistantBubble> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<div class="ai-bubble-container">
|
||||
@@ -134,7 +136,7 @@
|
||||
catch (Exception ex)
|
||||
{
|
||||
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
|
||||
Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
|
||||
Logger.LogError(ex, "[AiAssistantBubble] Error fetching summary for block {BlockId}.", ContextBlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<AiResponseRenderer> Logger
|
||||
|
||||
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
|
||||
<div class="message-avatar" aria-hidden="true">
|
||||
@if (Message.Sender == "User")
|
||||
{
|
||||
<i class="bi bi-person-fill"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-robot"></i>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="message-bubble @GetBubbleClass()">
|
||||
<div class="message-header">
|
||||
<span class="sender-name">@Message.Sender</span>
|
||||
<span class="message-time">@Message.Timestamp.ToString("HH:mm")</span>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
@if (Message.Sender == "User")
|
||||
{
|
||||
<p>@Message.Text</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_hasPaywall)
|
||||
{
|
||||
<div class="paywall-teaser" aria-hidden="true">
|
||||
@foreach (var segment in ParseSegments(_displayTeaserText))
|
||||
{
|
||||
@if (segment.IsCitation)
|
||||
{
|
||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderMarkdown(segment.Text)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="upsell-card" role="alert" aria-live="polite">
|
||||
<div class="upsell-header">
|
||||
<span class="upsell-icon" aria-hidden="true">🔒</span>
|
||||
<h4>Dostęp Premium Zablokowany</h4>
|
||||
</div>
|
||||
|
||||
<p class="upsell-text">
|
||||
Twoje zasoby odpowiadają na to pytanie w <strong>@_localScore%</strong>. W materiale <strong>'@_lockedBookTitle'</strong> znaleźliśmy odpowiedź dopasowaną w <strong>@_globalScore%</strong>.
|
||||
</p>
|
||||
|
||||
<div class="upsell-actions">
|
||||
@if (_isSimulatingPayment)
|
||||
{
|
||||
<button class="btn-upsell btn-primary loading" disabled aria-busy="true">
|
||||
<div class="payment-spinner" aria-hidden="true"></div>
|
||||
PRZETWARZANIE PŁATNOŚCI...
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn-upsell btn-primary" @onclick="HandlePurchase">
|
||||
ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
|
||||
</button>
|
||||
}
|
||||
<a href="/catalog?bookId=@_lockedBookId" class="btn-upsell btn-secondary">
|
||||
Zobacz szczegóły w Katalogu
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="full-response">
|
||||
@foreach (var segment in ParseSegments(GetCleanText()))
|
||||
{
|
||||
@if (segment.IsCitation)
|
||||
{
|
||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderMarkdown(segment.Text)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_showSuccessBanner)
|
||||
{
|
||||
<div class="success-unlock-banner" role="status">
|
||||
<span class="success-icon" aria-hidden="true">✓</span>
|
||||
<span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public ChatMessage Message { get; set; } = default!;
|
||||
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
|
||||
[Parameter] public EventCallback<Guid> OnUnlockRequested { get; set; }
|
||||
|
||||
private bool _hasPaywall;
|
||||
private string _displayTeaserText = string.Empty;
|
||||
private Guid _lockedBookId;
|
||||
private string _lockedBookTitle = string.Empty;
|
||||
private int _localScore;
|
||||
private int _globalScore;
|
||||
|
||||
private bool _isUnlocked = false;
|
||||
private bool _isSimulatingPayment = false;
|
||||
private bool _showSuccessBanner = false;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
|
||||
if (Message != null && Message.Sender != "User" && !_isUnlocked)
|
||||
{
|
||||
_hasPaywall = PaywallParser.TryParsePaywallTrigger(Message.Text, out _displayTeaserText, out _lockedBookId, out _lockedBookTitle, out _localScore, out _globalScore);
|
||||
|
||||
// Additional check: if user already owns the book, don't show the paywall
|
||||
if (_hasPaywall && OwnedBooks != null)
|
||||
{
|
||||
var isOwned = OwnedBooks.Any(b =>
|
||||
b.Id == _lockedBookId ||
|
||||
(!string.IsNullOrEmpty(b.Title) && b.Title.Equals(_lockedBookTitle, StringComparison.OrdinalIgnoreCase)));
|
||||
if (isOwned)
|
||||
{
|
||||
_hasPaywall = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_hasPaywall = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetCleanText()
|
||||
{
|
||||
if (Message == null) return string.Empty;
|
||||
if (PaywallParser.TryParsePaywallTrigger(Message.Text, out var cleanText, out _, out _, out _, out _))
|
||||
{
|
||||
return cleanText;
|
||||
}
|
||||
return Message.Text;
|
||||
}
|
||||
|
||||
private string GetBubbleClass()
|
||||
{
|
||||
if (Message.Sender == "User") return "user-bubble";
|
||||
return _hasPaywall ? "ai-bubble paywalled-bubble" : "ai-bubble";
|
||||
}
|
||||
|
||||
private async Task HandlePurchase()
|
||||
{
|
||||
if (_isSimulatingPayment) return;
|
||||
|
||||
_isSimulatingPayment = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Simulate payment gateway delay (1.5 seconds)
|
||||
await Task.Delay(1500);
|
||||
|
||||
try
|
||||
{
|
||||
var bookTitle = string.IsNullOrEmpty(_lockedBookTitle)
|
||||
? "Architektura .NET 10 i Ekosystem Blazor"
|
||||
: _lockedBookTitle;
|
||||
|
||||
// Call POST endpoint to persist the purchase
|
||||
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_isUnlocked = true;
|
||||
_hasPaywall = false;
|
||||
_showSuccessBanner = true;
|
||||
|
||||
// Fetch updated library list and update state manager
|
||||
var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
|
||||
LibraryStateService.OwnedBooks = updatedBooks;
|
||||
|
||||
if (OnUnlockRequested.HasDelegate)
|
||||
{
|
||||
await OnUnlockRequested.InvokeAsync(_lockedBookId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSimulatingPayment = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private List<ResponseSegment> ParseSegments(string text)
|
||||
{
|
||||
var segments = new List<ResponseSegment>();
|
||||
if (string.IsNullOrEmpty(text)) return segments;
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(
|
||||
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var matches = regex.Matches(text);
|
||||
|
||||
int lastIndex = 0;
|
||||
foreach (System.Text.RegularExpressions.Match match in matches)
|
||||
{
|
||||
if (match.Index > lastIndex)
|
||||
{
|
||||
segments.Add(new ResponseSegment
|
||||
{
|
||||
Text = text.Substring(lastIndex, match.Index - lastIndex),
|
||||
IsCitation = false
|
||||
});
|
||||
}
|
||||
|
||||
var citationId = match.Groups[1].Success
|
||||
? match.Groups[1].Value.Trim()
|
||||
: match.Groups[2].Value.Trim();
|
||||
|
||||
segments.Add(new ResponseSegment
|
||||
{
|
||||
IsCitation = true,
|
||||
CitationId = citationId
|
||||
});
|
||||
|
||||
lastIndex = match.Index + match.Length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.Length)
|
||||
{
|
||||
segments.Add(new ResponseSegment
|
||||
{
|
||||
Text = text.Substring(lastIndex),
|
||||
IsCitation = false
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private MarkupString RenderMarkdown(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
|
||||
|
||||
var html = System.Net.WebUtility.HtmlEncode(text);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
|
||||
html = html.Replace("\n", "<br />");
|
||||
|
||||
return new MarkupString(html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 90%;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: bubble-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
align-self: flex-end;
|
||||
margin-left: auto;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ai-row {
|
||||
align-self: flex-start;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-row .message-avatar {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 0 10px var(--border);
|
||||
}
|
||||
|
||||
.ai-row .message-avatar {
|
||||
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
|
||||
color: #e6fffa;
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
font-size: 0.975rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
border-top-right-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
border-top-left-radius: 4px;
|
||||
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.paywalled-bubble {
|
||||
border-color: var(--accent-glow);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Paragraph spacing */
|
||||
.message-content p {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Paywall Blur Styles */
|
||||
.paywall-teaser {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Upsell Card */
|
||||
.upsell-card {
|
||||
background: var(--bg-base);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--accent-glow);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 8px 32px var(--accent-glow), 0 4px 12px var(--border);
|
||||
animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.upsell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.upsell-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.upsell-header h4 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.upsell-text {
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 1.25rem 0;
|
||||
}
|
||||
|
||||
.upsell-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-upsell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.5px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--accent-glow);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--accent-glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Success Banner */
|
||||
.success-unlock-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--accent-glow);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
animation: fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Payment Spinner */
|
||||
.payment-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
margin-right: 0.75rem;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes bubble-fade-in {
|
||||
0% { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes card-slide-in {
|
||||
0% { opacity: 0; transform: translateY(10px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
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%);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
@namespace NexusReader.UI.Shared.Components.Molecules
|
||||
|
||||
<div class="nexus-callout-box nexus-callout-@Type.ToString().ToLower() @Class">
|
||||
@if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
<div class="nexus-callout-header">
|
||||
@if (Type == CalloutType.Warning || Type == CalloutType.Error)
|
||||
{
|
||||
<NexusIcon Name="warning" Size="16" Class="nexus-callout-icon" />
|
||||
}
|
||||
else if (Type == CalloutType.Success)
|
||||
{
|
||||
<NexusIcon Name="check" Size="16" Class="nexus-callout-icon" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<NexusIcon Name="info" Size="16" Class="nexus-callout-icon" />
|
||||
}
|
||||
<span class="nexus-callout-title">@Title</span>
|
||||
</div>
|
||||
}
|
||||
<div class="nexus-callout-body">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
public enum CalloutType
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Success,
|
||||
Error
|
||||
}
|
||||
|
||||
[Parameter]
|
||||
public CalloutType Type { get; set; } = CalloutType.Info;
|
||||
|
||||
[Parameter]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Class { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
.nexus-callout-box {
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 1.5rem 0 1.5rem 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-family: var(--nexus-font-sans, sans-serif);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
/* Light / Dark default support via variables or custom colors */
|
||||
.nexus-callout-box {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Info style */
|
||||
.nexus-callout-info {
|
||||
border-left-color: var(--nexus-neon, #00ff99);
|
||||
}
|
||||
|
||||
/* Warning style */
|
||||
.nexus-callout-warning {
|
||||
border-left-color: #eab308; /* warning yellow */
|
||||
background-color: rgba(234, 179, 8, 0.03);
|
||||
}
|
||||
|
||||
/* Success style */
|
||||
.nexus-callout-success {
|
||||
border-left-color: #10b981; /* success green */
|
||||
background-color: rgba(16, 185, 129, 0.03);
|
||||
}
|
||||
|
||||
/* Error style */
|
||||
.nexus-callout-error {
|
||||
border-left-color: #f43f5e; /* error red */
|
||||
background-color: rgba(244, 63, 94, 0.03);
|
||||
}
|
||||
|
||||
.nexus-callout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nexus-callout-info .nexus-callout-header {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
}
|
||||
|
||||
.nexus-callout-warning .nexus-callout-header {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.nexus-callout-success .nexus-callout-header {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.nexus-callout-error .nexus-callout-header {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.nexus-callout-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nexus-callout-body {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Light theme support */
|
||||
.theme-light .nexus-callout-box {
|
||||
background-color: #fcfcfb;
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
border-left-width: 4px;
|
||||
color: #44403c;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-info {
|
||||
border-left-color: #10b981;
|
||||
background-color: rgba(16, 185, 129, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-info .nexus-callout-header {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-warning {
|
||||
border-left-color: #d97706;
|
||||
background-color: rgba(217, 119, 6, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-warning .nexus-callout-header {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-success {
|
||||
border-left-color: #10b981;
|
||||
background-color: rgba(16, 185, 129, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-success .nexus-callout-header {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-error {
|
||||
border-left-color: #e11d48;
|
||||
background-color: rgba(225, 29, 72, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .nexus-callout-error .nexus-callout-header {
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Linq
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IThemeService ThemeService
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject ILogger<IntelligenceToolbar> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
<div class="toolbar-top">
|
||||
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Back to Dashboard">
|
||||
<NexusIcon Name="arrow-left" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item active" title="Chat">
|
||||
<NexusIcon Name="message-square" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-middle">
|
||||
<button class="toolbar-item" title="Settings">
|
||||
<NexusIcon Name="settings" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item" title="Bookmarks">
|
||||
<NexusIcon Name="bookmark" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item" title="Search">
|
||||
<NexusIcon Name="search" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item danger" @onclick="HandleClearCache" title="Clear AI Cache">
|
||||
<NexusIcon Name="trash" Size="20" />
|
||||
</button>
|
||||
@if (FocusMode.IsFocusModeActive)
|
||||
{
|
||||
<button class="toolbar-item active" @onclick="FocusMode.ToggleAsync" title="Focus Mode Active (Click to Exit)">
|
||||
<NexusIcon Name="target" Size="20" />
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="toolbar-item active" @onclick="FocusMode.ToggleAsync" title="Chat Active (Click to Focus)">
|
||||
<NexusIcon Name="message-square" Size="20" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="toolbar-bottom">
|
||||
<button class="toolbar-item @(FocusMode.IsFocusModeActive ? "active focus-active" : "")"
|
||||
@onclick="FocusMode.ToggleAsync" title="Focus Mode (F)">
|
||||
<NexusIcon Name="target" Size="20" />
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<button class="toolbar-item" @onclick="ThemeService.ToggleTheme" title="Przełącz motyw">
|
||||
<NexusIcon Name="@(ThemeService.IsLightMode ? "sun" : "moon")" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Global Hub">
|
||||
<NexusIcon Name="layers" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
|
||||
<NexusIcon Name="log-out" Size="20" />
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<button class="toolbar-item clear-cache-item" @onclick="HandleClearCache" title="Wyczyść pamięć AI">
|
||||
<NexusIcon Name="trash" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -48,29 +49,30 @@
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
}
|
||||
|
||||
private async Task HandleClearCache()
|
||||
{
|
||||
// For now, a simple console log confirm or just do it
|
||||
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Requesting cache clear...");
|
||||
var result = await KnowledgeService.ClearCacheAsync();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Cache cleared successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("[IntelligenceToolbar] Cache clear failed: {Errors}", string.Join("; ", result.Errors.Select(e => e.Message)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,26 +71,53 @@
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toolbar-item.danger:hover {
|
||||
color: #ff4d4d;
|
||||
background: rgba(255, 77, 77, 0.1);
|
||||
|
||||
|
||||
.toolbar-separator {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.toolbar-item.logout-item {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 1.5rem;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-radius: 0;
|
||||
color: #444;
|
||||
/* Light mode overrides */
|
||||
.theme-light .intelligence-toolbar {
|
||||
background: #f5f5f4;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: inset -2px 0 10px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.toolbar-item.logout-item:hover {
|
||||
color: #ff4d4d;
|
||||
background: none;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.4));
|
||||
.theme-light .toolbar-item {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-item:hover {
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-item.active {
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-item.active::after {
|
||||
background: #10b981;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-item.focus-active {
|
||||
color: #10b981;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-separator {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -335,3 +335,175 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
.theme-light .knowledge-check {
|
||||
background: #fafaf9;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.theme-light .header-title {
|
||||
color: #1c1917;
|
||||
}
|
||||
|
||||
.theme-light .question-text {
|
||||
color: #44403c;
|
||||
}
|
||||
|
||||
.theme-light .option-item {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.theme-light .option-item:hover {
|
||||
background: #f5f5f4;
|
||||
}
|
||||
|
||||
.theme-light .option-item.selected {
|
||||
border-color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .option-letter {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.theme-light .option-text {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .option-correct {
|
||||
border-color: #10b981 !important;
|
||||
background: rgba(16, 185, 129, 0.08) !important;
|
||||
}
|
||||
|
||||
.theme-light .option-incorrect {
|
||||
border-color: #f43f5e !important;
|
||||
background: rgba(244, 63, 94, 0.08) !important;
|
||||
}
|
||||
|
||||
.theme-light .option-revealed-correct {
|
||||
border-color: #10b981 !important;
|
||||
background: rgba(16, 185, 129, 0.06) !important;
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .loading-state.shimmer {
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.03), transparent);
|
||||
color: #10b981;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.theme-light .submit-btn {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.theme-light .submit-btn:not(:disabled) {
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .submitted-title {
|
||||
color: #1c1917;
|
||||
}
|
||||
|
||||
.theme-light .submitted-text {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.theme-light .score-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.theme-light .score-num {
|
||||
color: #10b981;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.theme-light .score-divider {
|
||||
color: #e7e5e4;
|
||||
}
|
||||
|
||||
.theme-light .score-total {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .score-percent {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.theme-light .reset-quiz-btn {
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
color: #44403c;
|
||||
}
|
||||
|
||||
.theme-light .reset-quiz-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-color: #1c1917;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.theme-light .empty-title {
|
||||
color: #1c1917;
|
||||
}
|
||||
|
||||
.theme-light .empty-text {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.theme-light .empty-icon-wrapper {
|
||||
background: rgba(16, 185, 129, 0.02);
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.02);
|
||||
}
|
||||
|
||||
.theme-light .empty-quiz-state:hover .empty-icon-wrapper {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
box-shadow: 0 0 25px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .generate-quiz-btn {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border: 1px solid #10b981;
|
||||
color: #10b981;
|
||||
text-shadow: none;
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .generate-quiz-btn:not(:disabled):hover {
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 0 25px rgba(16, 185, 129, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.theme-light .generate-quiz-btn:disabled {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
color: #a8a29e;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-light .success-icon-wrapper {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.theme-light .success-glow {
|
||||
color: #10b981;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.theme-light .neon-glow {
|
||||
color: #10b981;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject IQuizStateService QuizService
|
||||
@inject IJSRuntime JS
|
||||
@inject ILogger<SelectionAiPanel> Logger
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="selection-ai-panel expanded @(PositionBelow ? "below" : "")" style="@PanelStyle">
|
||||
<div class="ai-bubble">
|
||||
<div class="ai-avatar">
|
||||
<div class="avatar-ring"></div>
|
||||
<NexusIcon Name="robot" Size="48" Class="neon-pulse" />
|
||||
<div class="avatar-label">
|
||||
<span class="name">E-Czytnik</span>
|
||||
<span class="role">Asystent AI</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-content">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="loading-state">
|
||||
<div class="shimmer">Skanowanie fragmentu...</div>
|
||||
</div>
|
||||
}
|
||||
else if (Packet != null)
|
||||
{
|
||||
<div class="summary-box">
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Packet.Summary</NexusTypography>
|
||||
</div>
|
||||
<div class="ai-actions">
|
||||
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
|
||||
<button class="action-btn ghost" @onclick="CloseAsync">Zamknij</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="summary-box">
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?</NexusTypography>
|
||||
</div>
|
||||
<div class="ai-actions">
|
||||
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
|
||||
<button class="action-btn ghost" @onclick="CloseAsync">Pomiń</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="bubble-pointer"></div>
|
||||
</div>
|
||||
<div class="selection-ai-panel @(_positionBelow ? "below" : "")" style="@_style">
|
||||
<button id="summary-btn" class="toolbar-btn primary @(IsLoadingSummary ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
|
||||
disabled="@IsAnyLoading"
|
||||
@onclick="RequestSummaryAsync">
|
||||
@if (IsLoadingSummary)
|
||||
{
|
||||
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
|
||||
</svg>
|
||||
<span class="btn-text">Podsumowywanie...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<NexusIcon Name="book-open" Size="14" Class="btn-icon" />
|
||||
<span class="btn-text">Podsumuj</span>
|
||||
}
|
||||
</button>
|
||||
<div class="toolbar-divider"></div>
|
||||
<button id="quiz-btn" class="toolbar-btn secondary @(IsLoadingQuiz ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
|
||||
disabled="@IsAnyLoading"
|
||||
@onclick="GenerateQuizAsync">
|
||||
@if (IsLoadingQuiz)
|
||||
{
|
||||
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
|
||||
</svg>
|
||||
<span class="btn-text">Generowanie...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<NexusIcon Name="target" Size="14" Class="btn-icon" />
|
||||
<span class="btn-text">Quiz</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -56,47 +56,145 @@
|
||||
[Parameter] public string FullPageContent { get; set; } = string.Empty;
|
||||
|
||||
private bool IsVisible => !string.IsNullOrEmpty(SelectedText) && Coordinates != null;
|
||||
private bool IsLoading = false;
|
||||
private KnowledgePacket? Packet;
|
||||
private bool PositionBelow => Coordinates != null && Coordinates.Top < 320;
|
||||
private bool IsLoadingSummary = false;
|
||||
private bool IsLoadingQuiz = false;
|
||||
private bool IsAnyLoading => IsLoadingSummary || IsLoadingQuiz;
|
||||
|
||||
private string _style = "visibility: hidden; opacity: 0; pointer-events: none;";
|
||||
private bool _positionBelow = false;
|
||||
private SelectionCoordinates? _lastCoordinates;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}, PositionBelow: {PositionBelow}");
|
||||
// Reset packet when selection changes
|
||||
Packet = null;
|
||||
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top);
|
||||
|
||||
if (Coordinates != _lastCoordinates)
|
||||
{
|
||||
_lastCoordinates = Coordinates;
|
||||
_style = "visibility: hidden; opacity: 0; pointer-events: none;";
|
||||
_positionBelow = false;
|
||||
}
|
||||
|
||||
// Reset loading states when parameters change
|
||||
IsLoadingSummary = false;
|
||||
IsLoadingQuiz = false;
|
||||
}
|
||||
|
||||
private string PanelStyle => Coordinates != null
|
||||
? string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
||||
$"top: {(PositionBelow ? Coordinates.Top + 35 : Coordinates.Top - 15):F1}px !important; " +
|
||||
$"left: {Math.Clamp(Coordinates.Left + Coordinates.Width / 2, 280, 1600):F1}px !important; " +
|
||||
$"transform: translate(-50%, {(PositionBelow ? "0" : "-100%")}) !important;")
|
||||
: "";
|
||||
|
||||
private async Task RequestSummary()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
IsLoading = true;
|
||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
|
||||
Packet = result.IsSuccess ? result.Value : null;
|
||||
IsLoading = false;
|
||||
if (IsVisible && _style.Contains("visibility: hidden"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
||||
var result = await module.InvokeAsync<PositionResult>("positionToolbar");
|
||||
if (result != null)
|
||||
{
|
||||
_style = string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
||||
$"left: {result.Left:F1}px !important; " +
|
||||
$"top: {result.Top:F1}px !important; " +
|
||||
$"visibility: visible !important; " +
|
||||
$"opacity: 1 !important; " +
|
||||
$"pointer-events: auto !important;");
|
||||
_positionBelow = result.Below;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateFullQuiz()
|
||||
private async Task RequestSummaryAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
|
||||
IsLoading = false;
|
||||
await CloseAsync();
|
||||
if (IsAnyLoading) return;
|
||||
IsLoadingSummary = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
||||
var selectedText = await module.InvokeAsync<string>("getSelectionText");
|
||||
if (string.IsNullOrWhiteSpace(selectedText))
|
||||
{
|
||||
selectedText = SelectedText;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selectedText))
|
||||
{
|
||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
_ = Coordinator.StartSelectionSummaryAsync($"{contextPrompt}{selectedText}");
|
||||
await CloseAsync();
|
||||
await InteractionService.RequestAssistant();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingSummary = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateQuizAsync()
|
||||
{
|
||||
if (IsAnyLoading) return;
|
||||
IsLoadingQuiz = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
||||
var selectedText = await module.InvokeAsync<string>("getSelectionText");
|
||||
if (string.IsNullOrWhiteSpace(selectedText))
|
||||
{
|
||||
selectedText = SelectedText;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selectedText))
|
||||
{
|
||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{selectedText}");
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await CloseAsync();
|
||||
await QuizService.RequestQuiz(BlockId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingQuiz = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
Packet = null;
|
||||
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
|
||||
}
|
||||
|
||||
private class PositionResult
|
||||
{
|
||||
public double Left { get; set; }
|
||||
public double Top { get; set; }
|
||||
public bool Below { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,158 +1,149 @@
|
||||
.selection-ai-panel {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 550px;
|
||||
max-width: 90vw;
|
||||
animation: fadeInScale 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(24, 24, 28, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 6px;
|
||||
gap: 4px;
|
||||
pointer-events: none; /* Controlled by inline styles */
|
||||
user-select: none;
|
||||
animation: fadeInScale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from { opacity: 0; transform: translate(-50%, -90%) scale(0.95); }
|
||||
to { opacity: 1; transform: translate(-50%, -100%) scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(18, 18, 18, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
.selection-ai-panel.below {
|
||||
animation: fadeInScaleBelow 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
@keyframes fadeInScaleBelow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.avatar-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-label .name {
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #e4e4e7; /* zinc-200 */
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.avatar-label .role {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.neon-pulse {
|
||||
color: #00ff99;
|
||||
filter: drop-shadow(0 0 8px #00ff99);
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
|
||||
50% { transform: scale(1.05); filter: drop-shadow(0 0 15px #00ff99); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
|
||||
}
|
||||
|
||||
.ai-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: #e0e0e0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.summary-box::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.summary-box::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 153, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.action-btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #aaa;
|
||||
.toolbar-btn:hover:not(.disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.action-btn.neon-border {
|
||||
background: rgba(0, 255, 153, 0.1);
|
||||
border: 1px solid #00ff99;
|
||||
color: #00ff99;
|
||||
.toolbar-btn.primary {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 255, 153, 0.2);
|
||||
.toolbar-btn.primary:hover:not(.disabled) {
|
||||
background: rgba(0, 255, 153, 0.08);
|
||||
box-shadow: 0 0 12px rgba(0, 255, 153, 0.15);
|
||||
}
|
||||
|
||||
.bubble-pointer {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
.toolbar-btn.disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selection-ai-panel:not(.below) .bubble-pointer {
|
||||
bottom: -10px;
|
||||
border-top: 10px solid rgba(18, 18, 18, 0.95);
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.selection-ai-panel.below .bubble-pointer {
|
||||
top: -10px;
|
||||
border-bottom: 10px solid rgba(18, 18, 18, 0.95);
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 1rem;
|
||||
.spinner-inline {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 255, 153, 0.2), transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
.opacity-50 {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
.theme-light .selection-ai-panel {
|
||||
background: rgba(254, 254, 254, 0.95);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .toolbar-btn {
|
||||
color: #57524e;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-btn:hover:not(.disabled) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #1c1917;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-btn.primary {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .toolbar-btn.primary:hover:not(.disabled) {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .toolbar-divider {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verification-state" style="@(IsVerifying ? "display:flex;" : "display:none;")">
|
||||
<div class="verification-state" style="@((IsVerifying && !IsIngesting) ? "display:flex;" : "display:none;")">
|
||||
@if (Metadata != null)
|
||||
{
|
||||
<div class="verification-layout">
|
||||
|
||||
@@ -196,52 +196,37 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
::deep .nexus-btn.btn-primary {
|
||||
background: var(--nexus-neon, #00ffaa) !important;
|
||||
color: #050505 !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2) !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--nexus-neon, #00ffaa);
|
||||
color: #050505;
|
||||
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2);
|
||||
::deep .nexus-btn.btn-primary:hover:not(:disabled) {
|
||||
background: #00e699 !important;
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4) !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #00e699;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4);
|
||||
::deep .nexus-btn.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
::deep .nexus-btn.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
color: var(--nexus-text) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--nexus-text);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
::deep .nexus-btn.btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
||||
transform: translateY(-2px) !important;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
::deep .nexus-btn.btn-secondary:active:not(:disabled) {
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
|
||||
/* Verification State */
|
||||
@@ -357,27 +342,30 @@
|
||||
to { transform: scale(1.2); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
::deep .nexus-btn:disabled:not(.btn-loading) {
|
||||
opacity: 0.4 !important;
|
||||
cursor: not-allowed !important;
|
||||
filter: grayscale(1) !important;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
position: relative;
|
||||
::deep .nexus-btn.btn-loading {
|
||||
position: relative !important;
|
||||
color: transparent !important;
|
||||
opacity: 1 !important;
|
||||
cursor: wait !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.btn-loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: var(--nexus-neon, #00ffaa);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa));
|
||||
::deep .nexus-btn.btn-loading::after {
|
||||
content: "" !important;
|
||||
position: absolute !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
||||
border-top-color: var(--nexus-neon, #00ffaa) !important;
|
||||
border-radius: 50% !important;
|
||||
animation: spin 0.8s linear infinite !important;
|
||||
filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa)) !important;
|
||||
}
|
||||
|
||||
/* Indexing State */
|
||||
|
||||
@@ -233,3 +233,126 @@
|
||||
.lock-icon {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
@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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
/* 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">
|
||||
@if (Book != null)
|
||||
{
|
||||
<div class="card-layout">
|
||||
<div @key='"current-reading-book"' class="card-layout">
|
||||
<div class="book-cover">
|
||||
<img src="@(Book.CoverUrl ?? "https://via.placeholder.com/120x180?text=No+Cover")" alt="@Book.Title" />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div class="book-details">
|
||||
<div class="header-info">
|
||||
<h3 class="book-title">@Book.Title</h3>
|
||||
<h3 id="book-title-@Book.Id" class="book-title">@Book.Title</h3>
|
||||
<span class="author-name">by @Book.Author.Name</span>
|
||||
</div>
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-nexus outline" @onclick="HandleContinueReading">
|
||||
Continue Reading
|
||||
<button class="btn-nexus outline" @onclick="HandleContinueReading" aria-label="Kontynuuj czytanie">
|
||||
Kontynuuj czytanie
|
||||
<NexusIcon Name="arrow-right" Size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -51,16 +51,16 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div @key='"current-reading-empty"' class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<NexusIcon Name="book-open" Size="48" />
|
||||
</div>
|
||||
<div class="empty-text">
|
||||
<h3>Brak aktywnych lektur</h3>
|
||||
<p>Przejdź do biblioteki, aby rozpocząć przygodę z Nexus Reader.</p>
|
||||
<p>Przejdź do katalogu lub swoich książek, aby rozpocząć przygodę z Nexus Reader.</p>
|
||||
</div>
|
||||
<button class="btn-nexus primary" @onclick='() => NavigationManager.NavigateTo("/library")'>
|
||||
Przejdź do Biblioteki
|
||||
<button class="btn-nexus primary" @onclick='() => NavigationManager.NavigateTo("/my-books")'>
|
||||
Przejdź do Moich Książek
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
width: 100%;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.current-reading-card:hover {
|
||||
background: #1e1e24;
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-layout {
|
||||
@@ -55,7 +66,7 @@
|
||||
|
||||
.author-name {
|
||||
font-size: 0.9rem;
|
||||
color: #A0A0A0;
|
||||
color: #a1a1aa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -80,7 +91,7 @@
|
||||
}
|
||||
|
||||
.percentage {
|
||||
color: var(--nexus-neon);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
@@ -92,8 +103,8 @@
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--nexus-neon);
|
||||
box-shadow: 0 0 10px rgba(0, 255, 153, 0.4);
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.4);
|
||||
border-radius: 100px;
|
||||
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@@ -101,7 +112,7 @@
|
||||
.book-excerpt {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: #B0B0B0;
|
||||
color: #a1a1aa;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -134,26 +145,26 @@
|
||||
|
||||
.btn-nexus.outline {
|
||||
background: transparent;
|
||||
color: var(--nexus-neon);
|
||||
border: 1px solid rgba(0, 255, 153, 0.3);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-nexus.outline:hover {
|
||||
background: rgba(0, 255, 153, 0.05);
|
||||
border-color: var(--nexus-neon);
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border-color: #10b981;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 255, 153, 0.1);
|
||||
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.btn-nexus.primary {
|
||||
background: var(--nexus-neon);
|
||||
background: #10b981;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-nexus.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 5px 15px rgba(0, 255, 153, 0.2);
|
||||
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@@ -168,9 +179,9 @@
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: var(--nexus-neon);
|
||||
color: #10b981;
|
||||
opacity: 0.3;
|
||||
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.2));
|
||||
filter: drop-shadow(0 0 10px rgba(16, 185, 129, 0.2));
|
||||
}
|
||||
|
||||
.empty-text h3 {
|
||||
@@ -183,19 +194,155 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-layout {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 1.5rem;
|
||||
@media (max-width: 767px) {
|
||||
.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);
|
||||
}
|
||||
|
||||
.book-title, .chapter-name {
|
||||
.card-layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
align-self: center;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.book-details {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
font-size: 1.25rem;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.header-info, .chapter-progress {
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -543,3 +543,208 @@
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Light Mode Theme Overrides
|
||||
========================================================================== */
|
||||
|
||||
.theme-light .sheet-content {
|
||||
background: rgba(244, 241, 234, 0.95); /* Matches Premium Paper theme */
|
||||
border-top-color: rgba(16, 185, 129, 0.3);
|
||||
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.theme-light .sheet-drag-handle {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .sheet-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.theme-light .header-info h3 {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .header-info .subtitle {
|
||||
color: #7c766b;
|
||||
}
|
||||
|
||||
.theme-light .close-btn {
|
||||
color: #7c766b;
|
||||
}
|
||||
|
||||
.theme-light .close-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .welcome-container h4 {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .welcome-container p {
|
||||
color: #7c766b;
|
||||
}
|
||||
|
||||
.theme-light .ai-avatar-badge {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(6, 182, 212, 0.08) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.theme-light .ai-avatar-badge ::deep i {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .welcome-glow-icon {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.theme-light .welcome-glow-icon ::deep i {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Chat bubble styling overrides */
|
||||
.theme-light .ai-bubble {
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .ai-bubble .sender-name {
|
||||
color: #7c766b;
|
||||
}
|
||||
|
||||
.theme-light .user-bubble {
|
||||
background-color: rgba(16, 185, 129, 0.06);
|
||||
border-color: rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
|
||||
.theme-light .user-bubble .sender-name {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .message-time {
|
||||
color: #8c867b;
|
||||
}
|
||||
|
||||
.theme-light .message-text {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .message-text strong {
|
||||
color: #1a1917;
|
||||
}
|
||||
|
||||
/* Code block / Citation styling overrides */
|
||||
.theme-light .nexus-mobile-code-block {
|
||||
background-color: #faf8f5;
|
||||
border-left-color: #10b981;
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-inline-code {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-citation {
|
||||
background-color: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .typing-indicator span {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.theme-light .loading-label {
|
||||
color: #7c766b;
|
||||
}
|
||||
|
||||
/* Footer / Input overrides */
|
||||
.theme-light .sheet-footer {
|
||||
background-color: rgba(234, 230, 221, 0.6);
|
||||
border-top-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.theme-light .scope-indicator {
|
||||
color: #7c766b;
|
||||
}
|
||||
|
||||
.theme-light .scope-indicator ::deep i {
|
||||
color: #8c867b;
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-input {
|
||||
background-color: rgba(255, 255, 255, 0.85);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-input:focus {
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .send-btn.disabled {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Citation modal overrides */
|
||||
.theme-light .citation-modal {
|
||||
background: #ffffff;
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .modal-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .book-title {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .book-title ::deep i {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .modal-body {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .citation-author,
|
||||
.theme-light .citation-modal .citation-page {
|
||||
color: #7c766b;
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .citation-author strong,
|
||||
.theme-light .citation-modal .citation-page strong {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .citation-snippet {
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
border-left-color: #10b981;
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .modal-footer {
|
||||
border-top-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .btn-nexus {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(6, 182, 212, 0.08) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .citation-modal .btn-nexus:hover {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(6, 182, 212, 0.15) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,21 @@
|
||||
<div class="knowledge-graph-container @(GraphService.IsLoading ? "loading" : "")" id="@ContainerId">
|
||||
@if (GraphService.IsLoading || GraphService.CurrentGraphData == null)
|
||||
{
|
||||
<div class="loading-state">
|
||||
<div class="preloader-robot">
|
||||
<NexusIcon Name="robot" Size="64" Class="neon-pulse" />
|
||||
<div class="scan-line"></div>
|
||||
<div class="loading-state">
|
||||
<div class="preloader-robot">
|
||||
<NexusIcon Name="robot" Size="64" Class="neon-pulse" />
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Mapowanie relacji rozdziału...</NexusTypography>
|
||||
</div>
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Mapowanie relacji rozdziału...</NexusTypography>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="graph-controls">
|
||||
<button class="zoom-btn" @onclick="ZoomIn" title="Zoom In">+</button>
|
||||
<button class="zoom-btn" @onclick="ZoomOut" title="Zoom Out">−</button>
|
||||
<button class="zoom-btn reset" @onclick="ZoomReset" title="Reset">⟲</button>
|
||||
</div>
|
||||
<div class="graph-controls">
|
||||
<button class="zoom-btn" @onclick="ZoomIn" title="Zoom In">+</button>
|
||||
<button class="zoom-btn" @onclick="ZoomOut" title="Zoom Out">−</button>
|
||||
<button class="zoom-btn reset" @onclick="ZoomReset" title="Reset">⟲</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.knowledge-graph-container.loading > ::deep svg {
|
||||
.knowledge-graph-container.loading> ::deep svg {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -93,9 +93,20 @@
|
||||
}
|
||||
|
||||
@keyframes robot-pulse {
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
|
||||
50% { transform: scale(1.1); filter: drop-shadow(0 0 25px var(--nexus-neon)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
|
||||
0% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
filter: drop-shadow(0 0 25px var(--nexus-neon));
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
||||
}
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
@@ -111,9 +122,17 @@
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: 0; }
|
||||
50% { top: 100%; }
|
||||
100% { top: 0; }
|
||||
0% {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::deep .nexus-node-active {
|
||||
@@ -124,11 +143,24 @@
|
||||
}
|
||||
|
||||
::deep @keyframes neon-flash {
|
||||
0% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
|
||||
50% { filter: brightness(3) drop-shadow(0 0 30px var(--nexus-neon)); }
|
||||
100% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
|
||||
0% {
|
||||
filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon));
|
||||
}
|
||||
|
||||
50% {
|
||||
filter: brightness(3) drop-shadow(0 0 30px var(--nexus-neon));
|
||||
}
|
||||
|
||||
100% {
|
||||
filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon));
|
||||
}
|
||||
}
|
||||
|
||||
::deep .neon-flash-node {
|
||||
animation: neon-flash 0.8s ease-out;
|
||||
}
|
||||
|
||||
.knowledge-graph-container ::deep svg {
|
||||
background: radial-gradient(circle, #1a1a1a 0%, #121212 100%);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
@@ -3,56 +3,58 @@
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using NexusReader.Application.Utilities
|
||||
@namespace NexusReader.UI.Shared.Components.Organisms
|
||||
@implements IDisposable
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject IReaderStateService StateService
|
||||
@inject IThemeService ThemeService
|
||||
|
||||
<div class="nexus-unified-mobile-toolbar">
|
||||
<!-- LEFT SLOT: Progress & Section Checkpoints -->
|
||||
<div class="toolbar-slot left-slot" @onclick="ToggleCheckpoints" title="Rozdziały i checkpoints">
|
||||
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||
<!-- Tab 1: Progress (Postęp) -->
|
||||
<button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints">
|
||||
<div class="progress-ring-wrapper">
|
||||
<svg class="progress-ring" width="38" height="38">
|
||||
<circle class="progress-ring-track" stroke="rgba(255,255,255,0.06)" stroke-width="2.5" fill="transparent" r="16" cx="19" cy="19" />
|
||||
<circle class="progress-ring-indicator" stroke="var(--nexus-neon, #00FF99)" stroke-width="2.5" fill="transparent" r="16" cx="19" cy="19"
|
||||
stroke-dasharray="100.53" stroke-dashoffset="@GetDashOffset()" />
|
||||
<svg class="progress-ring" width="28" height="28">
|
||||
<circle class="progress-ring-track" stroke="rgba(139, 130, 115, 0.15)" stroke-width="2" fill="transparent" r="12" cx="14" cy="14" />
|
||||
<circle class="progress-ring-indicator" stroke="#10b981" stroke-width="2" fill="transparent" r="12" cx="14" cy="14"
|
||||
stroke-dasharray="75.4" stroke-dashoffset="@GetDashOffset()" />
|
||||
</svg>
|
||||
<span class="progress-text">@ScrollPercentage%</span>
|
||||
</div>
|
||||
<div class="progress-info">
|
||||
<span class="slot-label">Postęp</span>
|
||||
<span class="slot-desc">Checkpoints</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tab-label">Postęp</span>
|
||||
</button>
|
||||
|
||||
<!-- CENTER SLOT: Global AI Assistant Glowing Trigger -->
|
||||
<div class="toolbar-slot center-slot">
|
||||
<!-- Tab 2: Text (Tekst) -->
|
||||
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Reader ? "active" : "")"
|
||||
@onclick="() => ChangeTab(MobileReaderTab.Reader)"
|
||||
aria-label="Tekst">
|
||||
<NexusIcon Name="book-open" Size="18" />
|
||||
<span class="tab-label">Tekst</span>
|
||||
</button>
|
||||
|
||||
<!-- Tab 3: Center AI Button (Asystent AI) -->
|
||||
<div class="nav-toggle-btn center-ai-container">
|
||||
<button class="btn-nexus-ai-core" @onclick="HandleAssistantClick" aria-label="Asystent AI">
|
||||
<div class="pulse-ring"></div>
|
||||
<div class="pulse-ring-outer"></div>
|
||||
<NexusIcon Name="robot" Size="22" Class="ai-core-icon" />
|
||||
<NexusIcon Name="robot" Size="20" Class="ai-core-icon" />
|
||||
</button>
|
||||
<span class="tab-label">AI</span>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SLOT: Context View Toggles -->
|
||||
<div class="toolbar-slot right-slot">
|
||||
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Reader ? "active" : "")"
|
||||
@onclick="() => ChangeTab(MobileReaderTab.Reader)"
|
||||
aria-label="Tekst">
|
||||
<NexusIcon Name="book-open" Size="18" />
|
||||
<span>Tekst</span>
|
||||
</button>
|
||||
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Graph ? "active" : "")"
|
||||
@onclick="() => ChangeTab(MobileReaderTab.Graph)"
|
||||
aria-label="Graf">
|
||||
<NexusIcon Name="share-2" Size="18" />
|
||||
<span>Graf</span>
|
||||
</button>
|
||||
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Concepts ? "active" : "")"
|
||||
@onclick="() => ChangeTab(MobileReaderTab.Concepts)"
|
||||
aria-label="Mapa">
|
||||
<NexusIcon Name="map" Size="18" />
|
||||
<span>Mapa</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tab 4: Graph (Graf) -->
|
||||
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Graph ? "active" : "")"
|
||||
@onclick="() => ChangeTab(MobileReaderTab.Graph)"
|
||||
aria-label="Graf">
|
||||
<NexusIcon Name="share-2" Size="18" />
|
||||
<span class="tab-label">Graf</span>
|
||||
</button>
|
||||
|
||||
<!-- Tab 5: Map (Mapa) -->
|
||||
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Concepts ? "active" : "")"
|
||||
@onclick="() => ChangeTab(MobileReaderTab.Concepts)"
|
||||
aria-label="Mapa">
|
||||
<NexusIcon Name="map" Size="18" />
|
||||
<span class="tab-label">Mapa</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SECTION CHECKPOINTS OVERLAY -->
|
||||
@@ -107,11 +109,20 @@
|
||||
|
||||
private bool IsCheckpointsOpen { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
|
||||
}
|
||||
|
||||
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
private double GetDashOffset()
|
||||
{
|
||||
// Circumference of r=16 is 2 * pi * 16 = 100.53
|
||||
double circumference = 100.53;
|
||||
// Circumference of r=12 is 2 * pi * 12 = 75.40
|
||||
double circumference = 75.40;
|
||||
double progress = Math.Clamp(ScrollPercentage, 0, 100);
|
||||
return circumference - (progress / 100.0) * circumference;
|
||||
}
|
||||
@@ -148,4 +159,10 @@
|
||||
await OnAssistantClick.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,38 +4,83 @@
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 64px;
|
||||
background: rgba(18, 18, 18, 0.75);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
padding: 0 0.5rem;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||
box-sizing: border-box;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
overflow: visible; /* Critical to show elevated FAB */
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.toolbar-slot {
|
||||
.nexus-unified-mobile-toolbar.immersive-zen-mode {
|
||||
transform: translateY(calc(100% + 24px)) !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* Light Mode: Premium Paper Look */
|
||||
.nexus-unified-mobile-toolbar.theme-light {
|
||||
background: rgba(244, 241, 234, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(139, 130, 115, 0.18);
|
||||
box-shadow: 0 8px 30px rgba(139, 130, 115, 0.15);
|
||||
}
|
||||
|
||||
/* Dark Mode: Translucent Slate */
|
||||
.nexus-unified-mobile-toolbar.theme-dark {
|
||||
background: rgba(18, 18, 18, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.nav-toggle-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b8273; /* Inactive items earthy gray */
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* LEFT SLOT: Progress circular ring */
|
||||
.left-slot {
|
||||
justify-content: flex-start;
|
||||
gap: 0.65rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.nav-toggle-btn.active {
|
||||
color: #10b981; /* Active items vibrant green */
|
||||
}
|
||||
|
||||
.nav-toggle-btn ::deep .nexus-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-toggle-btn.active ::deep .nexus-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-ring-wrapper {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -45,65 +90,53 @@
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring-indicator {
|
||||
transition: stroke-dashoffset 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.slot-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.slot-desc {
|
||||
font-size: 0.6rem;
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-weight: 700;
|
||||
color: #8b8273;
|
||||
}
|
||||
|
||||
/* CENTER SLOT: Glowing AI Core Button */
|
||||
.center-slot {
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
.nav-toggle-btn.active .progress-text {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Center AI FAB container & button */
|
||||
.center-ai-container {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.btn-nexus-ai-core {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #00FF99 0%, #00F0FF 100%);
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0B0C10;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 153, 0.4);
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.4);
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.btn-nexus-ai-core:active {
|
||||
transform: translateY(-6px) scale(0.95);
|
||||
box-shadow: 0 0 10px rgba(0, 255, 153, 0.3);
|
||||
transform: translateX(-50%) translateY(-18px) scale(0.95);
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.ai-core-icon {
|
||||
color: #0b0c10;
|
||||
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2));
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.center-ai-container .tab-label {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* Pulse effects */
|
||||
@@ -114,7 +147,7 @@
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(0, 255, 153, 0.4);
|
||||
border: 2px solid rgba(16, 185, 129, 0.4);
|
||||
opacity: 0;
|
||||
animation: corePulse 2s cubic-bezier(0.24, 0, 0.38, 1) infinite;
|
||||
pointer-events: none;
|
||||
@@ -128,7 +161,7 @@
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
opacity: 0;
|
||||
animation: corePulseOuter 2.5s cubic-bezier(0.24, 0, 0.38, 1) infinite;
|
||||
pointer-events: none;
|
||||
@@ -147,43 +180,6 @@
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
|
||||
/* RIGHT SLOT: Layout Switching */
|
||||
.right-slot {
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nav-toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.nav-toggle-btn.active {
|
||||
color: var(--nexus-neon, #00FF99);
|
||||
background-color: rgba(0, 255, 153, 0.06);
|
||||
}
|
||||
|
||||
.nav-toggle-btn ::deep .nexus-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-toggle-btn.active ::deep .nexus-icon {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.nav-toggle-btn span {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* SECTION CHECKPOINTS OVERLAY */
|
||||
.checkpoints-overlay {
|
||||
@@ -360,3 +356,54 @@
|
||||
.checkpoint-item:active .arrow-icon {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Light Mode overrides for Checkpoints Overlay */
|
||||
.theme-light .checkpoints-sheet {
|
||||
background: rgba(244, 241, 234, 0.95);
|
||||
border-top: 1px solid rgba(139, 130, 115, 0.15);
|
||||
box-shadow: 0 -8px 30px rgba(139, 130, 115, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .checkpoints-header {
|
||||
border-bottom: 1px solid rgba(139, 130, 115, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .checkpoints-header h4 {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .close-checkpoints-btn {
|
||||
color: #8b8273;
|
||||
}
|
||||
|
||||
.theme-light .checkpoint-item {
|
||||
background-color: rgba(139, 130, 115, 0.03);
|
||||
border: 1px solid rgba(139, 130, 115, 0.06);
|
||||
}
|
||||
|
||||
.theme-light .checkpoint-item.active {
|
||||
background-color: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.theme-light .checkpoint-item.active .checkpoint-id {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.theme-light .checkpoint-item.active .indicator-dot {
|
||||
background-color: #10b981;
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
.theme-light .checkpoint-id {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .checkpoint-label {
|
||||
color: #8b8273;
|
||||
}
|
||||
|
||||
.theme-light .arrow-icon {
|
||||
color: #8b8273;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject ILogger<ReaderCanvas> Logger
|
||||
|
||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||
@if (_isMobile && ViewModel != null)
|
||||
{
|
||||
<header class="nexus-mobile-reader-header">
|
||||
<header class="nexus-mobile-reader-header @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
|
||||
<NexusIcon Name="chevron-left" Size="18" />
|
||||
<span>Pulpit</span>
|
||||
@@ -39,6 +39,29 @@
|
||||
<NexusIcon Name="chevron-right" Size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="nexus-theme-toggle-btn" @onclick="ThemeService.ToggleTheme" aria-label="Przełącz motyw" title="Przełącz motyw">
|
||||
@if (ThemeService.IsLightMode)
|
||||
{
|
||||
<svg class="theme-toggle-icon sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M6.34 17.66l-1.41 1.41"></path>
|
||||
<path d="M19.07 4.93l-1.41 1.41"></path>
|
||||
</svg>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg class="theme-toggle-icon moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</header>
|
||||
}
|
||||
|
||||
@@ -99,13 +122,15 @@
|
||||
private bool _isMobile = false;
|
||||
private DotNetObjectReference<ReaderCanvas>? _selfReference;
|
||||
private IJSObjectReference? _viewportModule;
|
||||
private IJSObjectReference? _selectionModule;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Coordinator.ClearAsync();
|
||||
ThemeService.OnThemeChanged += HandleUpdate;
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||
QuizService.OnQuizUpdated += HandleUpdate;
|
||||
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
|
||||
|
||||
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
||||
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
||||
@@ -201,10 +226,13 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
||||
if (_selectionModule == null)
|
||||
{
|
||||
_selectionModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
||||
}
|
||||
if (_selfReference != null)
|
||||
{
|
||||
await module.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
|
||||
await _selectionModule.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -223,7 +251,7 @@
|
||||
if (_selfReference != null)
|
||||
{
|
||||
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
|
||||
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-flow-container");
|
||||
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-canvas");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -239,6 +267,17 @@
|
||||
await InteractionService.NotifyScrollPercentChanged(percent);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task HandleScrollDelta(bool hideBars)
|
||||
{
|
||||
if (StateService.IsBarsHidden != hideBars)
|
||||
{
|
||||
StateService.IsBarsHidden = hideBars;
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
[JSInvokable]
|
||||
public async Task HandleBlockReached(string blockId, string content)
|
||||
{
|
||||
@@ -405,8 +444,16 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
// Ensure the JS module is loaded and the component is fully rendered before invoking interop.
|
||||
if (!_isJsInitialized)
|
||||
{
|
||||
await InitViewportDetectionAsync();
|
||||
}
|
||||
var module = await EnsureViewportModuleAsync();
|
||||
await module.InvokeVoidAsync("scrollToTop", ".reader-canvas");
|
||||
if (module != null)
|
||||
{
|
||||
await module.InvokeVoidAsync("scrollToTop", ".reader-canvas");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -416,6 +463,8 @@
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void HandleEscape()
|
||||
{
|
||||
if (ViewModel != null)
|
||||
@@ -431,15 +480,29 @@
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
ThemeService.OnThemeChanged -= HandleUpdate;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
|
||||
|
||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||
InteractionService.OnTextSelected -= HandleTextSelected;
|
||||
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
||||
|
||||
try
|
||||
{
|
||||
if (_selectionModule != null)
|
||||
{
|
||||
await _selectionModule.InvokeVoidAsync("destroySelectionListener");
|
||||
await _selectionModule.DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Failed to destroy JS selection listener.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_viewportModule != null)
|
||||
|
||||
@@ -30,50 +30,120 @@
|
||||
background-color: rgba(0, 255, 153, 0.5);
|
||||
}
|
||||
|
||||
.reader-canvas.theme-dark {
|
||||
background-color: #121214;
|
||||
}
|
||||
|
||||
.reader-canvas.theme-light {
|
||||
background-color: #F9F9F9; /* Paper-white requirement */
|
||||
background-color: #f4f1ea;
|
||||
/* Warm light beige/gray background */
|
||||
}
|
||||
|
||||
.reader-flow-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
max-width: 680px;
|
||||
margin: 2rem auto;
|
||||
min-height: calc(100vh - 180px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
padding: 0 1.5rem 15rem 1.5rem; /* Large padding-bottom for reachability */
|
||||
padding: 3rem 4rem 15rem 4rem;
|
||||
/* Large padding-bottom for reachability, plus comfortable side margins */
|
||||
border-radius: 12px;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.3s, box-shadow 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
.theme-dark .reader-flow-container {
|
||||
background-color: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .reader-flow-container {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
box-shadow: 0 4px 20px rgba(139, 130, 115, 0.12);
|
||||
}
|
||||
|
||||
.block-wrapper {
|
||||
transition: all 0.5s ease;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Pull subsequent block closer to headings or bold exercise labels */
|
||||
.block-wrapper:has(h1),
|
||||
.block-wrapper:has(h2),
|
||||
.block-wrapper:has(h3),
|
||||
.block-wrapper:has(h4),
|
||||
.block-wrapper:has(h5),
|
||||
.block-wrapper:has(h6),
|
||||
.block-wrapper:has(p > strong) {
|
||||
margin-bottom: -0.25rem;
|
||||
}
|
||||
|
||||
/* Typographic refinement for TextSegmentBlock */
|
||||
::deep .nexus-ebook {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
line-height: 1.65 !important;
|
||||
letter-spacing: -0.01em !important;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 300;
|
||||
font-weight: 400;
|
||||
text-align: left !important;
|
||||
color: #e4e4e7;
|
||||
/* Off-white with light gray tint */
|
||||
}
|
||||
|
||||
.theme-light ::deep .nexus-ebook {
|
||||
color: #1a1a1a;
|
||||
color: #292524;
|
||||
/* Warm charcoal for legibility */
|
||||
}
|
||||
|
||||
/* Reset default margins for elements within separate block-wrappers */
|
||||
::deep .nexus-ebook p,
|
||||
::deep .nexus-ebook h1,
|
||||
::deep .nexus-ebook h2,
|
||||
::deep .nexus-ebook h3,
|
||||
::deep .nexus-ebook h4,
|
||||
::deep .nexus-ebook h5,
|
||||
::deep .nexus-ebook h6 {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Callout Box styling for legacy blockquote segments */
|
||||
::deep .nexus-ebook blockquote {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
border-left: 4px solid var(--nexus-neon);
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 1rem 0 1rem 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 1.05rem;
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.theme-light ::deep .nexus-ebook blockquote {
|
||||
background-color: rgba(245, 158, 11, 0.04);
|
||||
border-left: 4px solid #f59e0b;
|
||||
color: #44403c;
|
||||
}
|
||||
|
||||
|
||||
/* Technical Code Block Container */
|
||||
::deep .nexus-ebook pre {
|
||||
background-color: #2d2d2d; /* Dark theme for code for better contrast */
|
||||
background-color: #2d2d2d;
|
||||
/* Dark theme for code for better contrast */
|
||||
color: #e0e0e0;
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin: 2rem 0;
|
||||
margin: 1.25rem 0;
|
||||
overflow-x: auto;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-left: 4px solid var(--nexus-neon); /* Nexus neon accent */
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-left: 4px solid var(--nexus-neon);
|
||||
/* Nexus neon accent */
|
||||
|
||||
/* Dedicated Scrollbar for Code */
|
||||
scrollbar-width: thin;
|
||||
@@ -101,7 +171,8 @@
|
||||
/* Inline Code Highlight */
|
||||
::deep .nexus-ebook p code {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: #d63384; /* Classic differentiator for inline code */
|
||||
color: #d63384;
|
||||
/* Classic differentiator for inline code */
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
@@ -153,9 +224,20 @@
|
||||
}
|
||||
|
||||
@keyframes pulse-small {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chapter Loading Overlay and Spinners */
|
||||
@@ -246,32 +328,74 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0.9) translateY(10px); opacity: 0; }
|
||||
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||
from {
|
||||
transform: scale(0.9) translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* MOBILE READER UI OVERRIDES */
|
||||
@media (max-width: 768px) {
|
||||
.reader-canvas {
|
||||
padding-top: 54px !important;
|
||||
padding-bottom: 80px !important; /* Ensure content is clear of bottom toolbar */
|
||||
padding-bottom: 120px !important;
|
||||
/* Ensure content is clear of bottom toolbar */
|
||||
}
|
||||
|
||||
.reader-canvas.immersive-zen-mode {
|
||||
padding-top: calc(10px + env(safe-area-inset-top, 0px)) !important;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
}
|
||||
|
||||
.reader-flow-container {
|
||||
padding-bottom: 4rem; /* Safe breathing room */
|
||||
padding-left: 18px !important;
|
||||
padding-right: 18px !important;
|
||||
padding-bottom: 4rem;
|
||||
gap: 0.75rem !important; /* Tighter spacing on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
::deep .nexus-ebook,
|
||||
::deep .nexus-ebook p {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.55 !important;
|
||||
}
|
||||
|
||||
::deep .nexus-ebook h1 {
|
||||
font-size: 1.35rem !important;
|
||||
line-height: 1.4 !important;
|
||||
margin-top: 0.5rem !important; /* Tighter margins on mobile */
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.nexus-mobile-reader-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -287,8 +411,14 @@
|
||||
padding: 0 1rem;
|
||||
z-index: 1000;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.nexus-mobile-reader-header.immersive-zen-mode {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
|
||||
.theme-light .nexus-mobile-reader-header {
|
||||
background: rgba(249, 249, 249, 0.8);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
@@ -322,6 +452,7 @@
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
margin-right: 40px; /* Space to prevent overlap with absolute theme toggle button */
|
||||
}
|
||||
|
||||
.nexus-mobile-chapter-title {
|
||||
@@ -338,7 +469,20 @@
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-chapter-title {
|
||||
color: #1a1a1a;
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-escape-btn {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-escape-btn:hover {
|
||||
color: #10b981;
|
||||
background-color: rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .nexus-mobile-escape-btn:active {
|
||||
background-color: rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.nexus-chapter-nav-btn {
|
||||
@@ -373,3 +517,130 @@
|
||||
.theme-light .nexus-chapter-nav-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Minimalist Theme Toggle Button with Glassmorphism */
|
||||
.nexus-theme-toggle-btn {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
padding: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.nexus-theme-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
.nexus-theme-toggle-btn:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
/* Theme specifics for Light Mode */
|
||||
.theme-light .nexus-theme-toggle-btn {
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .nexus-theme-toggle-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Icon Styling and Transition */
|
||||
.theme-toggle-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* In Dark Mode, the icon should be brand green (#10b981) */
|
||||
.theme-toggle-icon.moon {
|
||||
color: #10b981;
|
||||
filter: drop-shadow(0 0 4px rgba(16, 185, 129, 0.3));
|
||||
animation: morphMoon 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
/* In Light Mode, the icon should be a dark earthy gray (#2d2a26) */
|
||||
.theme-toggle-icon.sun {
|
||||
color: #2d2a26;
|
||||
animation: morphSun 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes morphMoon {
|
||||
0% {
|
||||
transform: rotate(-45deg) scale(0.7);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes morphSun {
|
||||
0% {
|
||||
transform: rotate(45deg) scale(0.7);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ebook Image Scaling, Alignment, and Separation Lines */
|
||||
.block-wrapper:has(img) {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 1rem 0;
|
||||
margin: 0.5rem 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-light .block-wrapper:has(img) {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
::deep .nexus-ebook img {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
::deep .nexus-ebook img:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-light ::deep .nexus-ebook img {
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,64 +1,99 @@
|
||||
.reader-footer {
|
||||
position: relative;
|
||||
height: 50px;
|
||||
background: #F9F9F9;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(600px, 90%);
|
||||
height: 54px;
|
||||
background: rgba(24, 24, 27, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.theme-light .reader-footer {
|
||||
background: rgba(254, 254, 254, 0.75);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1.5rem;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navigation-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: white;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #333;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
color: #a1a1aa; /* Zinc-400 default contrast */
|
||||
}
|
||||
|
||||
.nav-btn:hover:not(:disabled) {
|
||||
background: #f0f0f0;
|
||||
transform: translateY(-1px);
|
||||
.nav-btn:hover:not(:disabled),
|
||||
.nav-btn:focus:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--nexus-neon, #00ff99);
|
||||
color: var(--nexus-neon, #00ff99); /* Brand neon green hover/focus signal */
|
||||
transform: scale(1.05);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.theme-light .nav-btn {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
color: #78716c; /* Warm stone-500 */
|
||||
}
|
||||
|
||||
.theme-light .nav-btn:hover:not(:disabled),
|
||||
.theme-light .nav-btn:focus:not(:disabled) {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chapter-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
color: #e2e8f0; /* Slate-200 for clean high readability */
|
||||
}
|
||||
|
||||
.theme-light .chapter-info {
|
||||
color: #292524; /* Warm charcoal for legibility */
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
@@ -68,38 +103,55 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chapter-count {
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
color: #a1a1aa; /* Zinc-400 for secondary info clarity */
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.theme-light .chapter-count {
|
||||
color: #78716c; /* Warm stone-500 secondary info */
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 3px;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin: 0 1rem;
|
||||
margin: 0 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-light .progress-container {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #2ECC71;
|
||||
border-radius: 3px;
|
||||
background: var(--nexus-neon, #00ff99);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-light .progress-bar {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
color: #a1a1aa;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.theme-light .meta-info {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.battery {
|
||||
@@ -107,3 +159,10 @@
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* RWD constraint: floating toolbar visible ONLY on desktop (min-width: 1024px) */
|
||||
@media (max-width: 1023px) {
|
||||
.reader-footer {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@using NexusReader.UI.Shared.Components.Molecules
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@@ -6,13 +7,13 @@
|
||||
|
||||
@if (!_isFullyLoaded)
|
||||
{
|
||||
<div class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000;">
|
||||
<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="preloader-spinner"></div>
|
||||
<div class="preloader-text">Synchronizing Secure Session...</div>
|
||||
<div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
|
||||
<div @key='"hub-container"' class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "") @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<!-- Mobile Sticky Top-bar -->
|
||||
@@ -46,49 +47,60 @@
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All" @onclick="CloseMobileMenu">
|
||||
<NavLink class="nav-item" href="/dashboard" Match="NavLinkMatch.All" @onclick="CloseMobileMenu" title="Pulpit" aria-label="Pulpit">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="home" Size="18" />
|
||||
<NexusIcon Name="home" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
<span class="nav-text">Pulpit</span>
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="/library" @onclick="CloseMobileMenu">
|
||||
<NavLink class="nav-item" href="/catalog" @onclick="CloseMobileMenu" title="Katalog" aria-label="Katalog">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="book-open" Size="18" />
|
||||
<NexusIcon Name="layout" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Library</span>
|
||||
<span class="nav-text">Katalog</span>
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="/concepts-map" @onclick="CloseMobileMenu">
|
||||
<NavLink class="nav-item" href="/my-books" @onclick="CloseMobileMenu" title="Moje" aria-label="Moje">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="map" Size="18" />
|
||||
<NexusIcon Name="book-open" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Concepts Map</span>
|
||||
<span class="nav-text">Moje</span>
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="/intelligence" @onclick="CloseMobileMenu">
|
||||
<NavLink class="nav-item" href="/profile" @onclick="CloseMobileMenu" title="Konto" aria-label="Konto">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="cpu" Size="18" />
|
||||
<NexusIcon Name="user" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Global AI Q&A</span>
|
||||
<span class="nav-text">Konto</span>
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="/profile" @onclick="CloseMobileMenu">
|
||||
<NavLink class="nav-item" href="/concepts-map" @onclick="CloseMobileMenu" title="Mapa Pojęć" aria-label="Mapa Pojęć">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="message-square" Size="18" />
|
||||
<NexusIcon Name="map" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Profile</span>
|
||||
<span class="nav-text">Mapa Pojęć</span>
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="/settings" @onclick="CloseMobileMenu">
|
||||
<NavLink class="nav-item" href="/intelligence" @onclick="CloseMobileMenu" title="Globalne AI" aria-label="Globalne AI">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="settings" Size="18" />
|
||||
<NexusIcon Name="robot" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Settings</span>
|
||||
<span class="nav-text">Globalne AI</span>
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="/concenters" @onclick="CloseMobileMenu">
|
||||
<NavLink class="nav-item" href="/settings" @onclick="CloseMobileMenu" title="Ustawienia" aria-label="Ustawienia">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="target" Size="18" />
|
||||
<NexusIcon Name="settings" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Concenters</span>
|
||||
<span class="nav-text">Ustawienia</span>
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="/concenters" @onclick="CloseMobileMenu" title="Koncentry" aria-label="Koncentry">
|
||||
<div class="nav-icon">
|
||||
<NexusIcon Name="target" Size="20" />
|
||||
</div>
|
||||
<span class="nav-text">Koncentry</span>
|
||||
</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>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
@@ -100,11 +112,37 @@
|
||||
<span class="user-name">@context.User.Identity?.Name</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="logout-btn" @onclick="HandleLogout" title="Logout">
|
||||
<NexusIcon Name="log-out" Size="18" />
|
||||
<button class="logout-btn" @onclick="HandleLogout" title="Wyloguj">
|
||||
<NexusIcon Name="log-out" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -119,6 +157,7 @@
|
||||
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
|
||||
[Inject] private IIdentityService IdentityService { get; set; } = default!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||
[Inject] private IThemeService ThemeService { get; set; } = default!;
|
||||
|
||||
private bool _isSyncing = false;
|
||||
private bool _isMobileMenuOpen = false;
|
||||
@@ -126,6 +165,8 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
|
||||
if (_isSyncing) return;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
@@ -137,10 +178,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
private void HandleThemeChanged(ThemeMode mode)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await ThemeService.InitializeAsync();
|
||||
_isFullyLoaded = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
@@ -162,4 +209,10 @@
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,163 +2,162 @@
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #121212;
|
||||
color: #e0e0e0;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-main);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::deep .hub-sidebar {
|
||||
width: 260px;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
background: #161616;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
::deep .sidebar-header {
|
||||
padding: 2.5rem 1.5rem;
|
||||
padding: 2rem 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
::deep .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
::deep .logo-icon {
|
||||
color: var(--nexus-neon);
|
||||
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.4));
|
||||
color: #10b981;
|
||||
filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.3));
|
||||
}
|
||||
|
||||
::deep .logo-text {
|
||||
font-family: var(--nexus-font-serif);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.01em;
|
||||
display: none;
|
||||
}
|
||||
|
||||
::deep .sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
::deep .nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
color: #A0A0A0;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
::deep .nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: #ffffff;
|
||||
color: #10b981;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
::deep .nav-item:focus-visible {
|
||||
outline: 2px solid #10b981;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
::deep .nav-item.active {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 255, 153, 0.03);
|
||||
border-left: 3px solid var(--nexus-neon);
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
}
|
||||
|
||||
::deep .nav-item.active .nav-icon {
|
||||
color: var(--nexus-neon);
|
||||
::deep .nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 15%;
|
||||
height: 70%;
|
||||
width: 3px;
|
||||
background: #10b981;
|
||||
border-radius: 0 4px 4px 0;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
::deep .nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
::deep .nav-item:hover .nav-icon,
|
||||
::deep .nav-item.active .nav-icon {
|
||||
opacity: 1;
|
||||
::deep .nav-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::deep .sidebar-footer {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 1.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
::deep .user-brief {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::deep .user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #222;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #A0A0A0;
|
||||
color: var(--text-main);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::deep .user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::deep .user-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #A0A0A0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: none;
|
||||
}
|
||||
|
||||
::deep .logout-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
::deep .logout-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ffffff;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.hub-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(circle at center, #1a1a1a 0%, #121212 100%);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.hub-content {
|
||||
@@ -179,11 +178,11 @@
|
||||
::deep .nexus-loader {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid rgba(0, 255, 153, 0.1);
|
||||
border-top-color: var(--nexus-neon);
|
||||
border: 2px solid rgba(16, 185, 129, 0.1);
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
filter: drop-shadow(0 0 5px var(--nexus-neon));
|
||||
filter: drop-shadow(0 0 5px #10b981);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -195,7 +194,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.reader-mobile-dock {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.nexus-mobile-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -205,10 +208,10 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: rgba(18, 18, 18, 0.85);
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 1.25rem;
|
||||
z-index: 150;
|
||||
}
|
||||
@@ -263,7 +266,7 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, var(--nexus-neon) 0%, #0099ff 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -296,7 +299,7 @@
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
background: #141414;
|
||||
background: var(--bg-surface);
|
||||
z-index: 200;
|
||||
transform: translateX(-100%);
|
||||
will-change: transform;
|
||||
@@ -325,7 +328,7 @@
|
||||
|
||||
::deep .sidebar-header {
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
::deep .sidebar-nav {
|
||||
@@ -343,4 +346,217 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
|
||||
@inject IThemeService ThemeService
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@implements IAsyncDisposable
|
||||
|
||||
|
||||
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}")">
|
||||
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}") @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||
<div class="reader-pane">
|
||||
<main>
|
||||
@Body
|
||||
@@ -62,7 +64,32 @@
|
||||
<span class="panel-title">Contextual Intelligence Panel</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@if (_selectedNode != null)
|
||||
@if (Coordinator.IsLoadingSelectionSummary)
|
||||
{
|
||||
<div class="skeleton-container">
|
||||
<div class="skeleton-line title"></div>
|
||||
<div class="skeleton-line w-90"></div>
|
||||
<div class="skeleton-line w-80"></div>
|
||||
<div class="skeleton-line w-70"></div>
|
||||
<div class="skeleton-line w-60"></div>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(Coordinator.SelectionSummary))
|
||||
{
|
||||
<div class="node-details">
|
||||
<div class="node-header-section">
|
||||
<div class="summary-badge-row">
|
||||
<span class="node-group-badge current">PODSUMOWANIE</span>
|
||||
<button class="clear-summary-btn" @onclick="ClearSelectionSummary" title="Wyczyść podsumowanie">×</button>
|
||||
</div>
|
||||
<h3 class="node-label">Zaznaczony Fragment</h3>
|
||||
</div>
|
||||
<div class="detail-section summary-section">
|
||||
<p class="node-summary">@Coordinator.SelectionSummary</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_selectedNode != null)
|
||||
{
|
||||
<div class="node-details">
|
||||
<div class="node-header-section">
|
||||
@@ -165,7 +192,32 @@
|
||||
{
|
||||
<div class="contextual-intelligence-panel">
|
||||
<div class="panel-body">
|
||||
@if (_selectedNode != null)
|
||||
@if (Coordinator.IsLoadingSelectionSummary)
|
||||
{
|
||||
<div class="skeleton-container">
|
||||
<div class="skeleton-line title"></div>
|
||||
<div class="skeleton-line w-90"></div>
|
||||
<div class="skeleton-line w-80"></div>
|
||||
<div class="skeleton-line w-70"></div>
|
||||
<div class="skeleton-line w-60"></div>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(Coordinator.SelectionSummary))
|
||||
{
|
||||
<div class="node-details">
|
||||
<div class="node-header-section">
|
||||
<div class="summary-badge-row">
|
||||
<span class="node-group-badge current">PODSUMOWANIE</span>
|
||||
<button class="clear-summary-btn" @onclick="ClearSelectionSummary" title="Wyczyść podsumowanie">×</button>
|
||||
</div>
|
||||
<h3 class="node-label">Zaznaczony Fragment</h3>
|
||||
</div>
|
||||
<div class="detail-section summary-section">
|
||||
<p class="node-summary">@Coordinator.SelectionSummary</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_selectedNode != null)
|
||||
{
|
||||
<div class="node-details">
|
||||
<div class="node-header-section">
|
||||
@@ -291,6 +343,8 @@
|
||||
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
|
||||
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
|
||||
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
|
||||
|
||||
var context = PlatformService.GetDeviceContext();
|
||||
if (context.IsSuccess)
|
||||
@@ -305,6 +359,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void SetActiveTab(SidebarTab tab)
|
||||
{
|
||||
_activeTab = tab;
|
||||
@@ -329,6 +385,11 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ClearSelectionSummary()
|
||||
{
|
||||
await Coordinator.ClearSelectionSummaryAsync();
|
||||
}
|
||||
|
||||
private async Task HandleScrollPercentChanged(int percent)
|
||||
{
|
||||
_scrollPercentage = percent;
|
||||
@@ -349,12 +410,24 @@
|
||||
{
|
||||
if (_isMobile)
|
||||
{
|
||||
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
|
||||
{
|
||||
_activeMobileTab = MobileReaderTab.Concepts;
|
||||
_activeTab = SidebarTab.Knowledge;
|
||||
}
|
||||
OpenAssistant();
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeMobileTab = MobileReaderTab.Concepts;
|
||||
_activeTab = SidebarTab.Quiz;
|
||||
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
|
||||
{
|
||||
_activeTab = SidebarTab.Knowledge;
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeTab = SidebarTab.Quiz;
|
||||
}
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
@@ -385,6 +458,8 @@
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await ThemeService.InitializeAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/layoutResizer.js");
|
||||
@@ -445,6 +520,8 @@
|
||||
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
||||
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
|
||||
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #121212;
|
||||
background: var(--nexus-bg);
|
||||
}
|
||||
|
||||
|
||||
.reader-pane {
|
||||
background: #F9F9F9;
|
||||
grid-column: 1;
|
||||
background: var(--nexus-bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 5;
|
||||
height: 100vh;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -27,30 +29,65 @@ main {
|
||||
}
|
||||
|
||||
.intelligence-sidebar {
|
||||
grid-column: 3;
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
width: 100%; /* controlled by grid */
|
||||
width: 100%;
|
||||
/* controlled by grid */
|
||||
height: 100%;
|
||||
background: #0d0d0d;
|
||||
background: var(--nexus-card);
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 4px;
|
||||
grid-column: 2;
|
||||
width: 12px;
|
||||
cursor: col-resize;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
transition: background 0.2s, width 0.2s;
|
||||
background: transparent;
|
||||
z-index: 20;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.resizer:hover, .app-container.is-resizing .resizer {
|
||||
.resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.resizer::after {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 60px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.resizer:hover::before,
|
||||
.app-container.is-resizing .resizer::before {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.resizer:hover::after,
|
||||
.app-container.is-resizing .resizer::after {
|
||||
background: var(--nexus-neon);
|
||||
width: 6px;
|
||||
box-shadow: 0 0 10px var(--nexus-neon);
|
||||
height: 80px;
|
||||
box-shadow: 0 0 12px var(--nexus-neon);
|
||||
}
|
||||
|
||||
.app-container.is-resizing {
|
||||
@@ -63,6 +100,7 @@ main {
|
||||
}
|
||||
|
||||
.app-container.focus-mode-active .intelligence-sidebar {
|
||||
grid-column: 3;
|
||||
grid-template-columns: 50px 0px;
|
||||
}
|
||||
|
||||
@@ -94,7 +132,7 @@ main {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 0.9rem;
|
||||
color: #fff;
|
||||
color: var(--nexus-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -149,9 +187,20 @@ main {
|
||||
}
|
||||
|
||||
@keyframes quiz-pulse {
|
||||
0% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
|
||||
50% { filter: drop-shadow(0 0 10px var(--nexus-neon)); transform: scale(1.1); }
|
||||
100% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
|
||||
0% {
|
||||
filter: drop-shadow(0 0 2px var(--nexus-neon));
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
filter: drop-shadow(0 0 2px var(--nexus-neon));
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Contextual Intelligence Panel Layout */
|
||||
@@ -226,9 +275,20 @@ main {
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
50% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(0.9); opacity: 0.5; }
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
@@ -245,8 +305,15 @@ main {
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.node-header-section {
|
||||
@@ -432,9 +499,20 @@ main {
|
||||
}
|
||||
|
||||
@keyframes quiz-pulse-glow {
|
||||
0% { border-color: rgba(0, 240, 255, 0.3); box-shadow: 0 0 5px rgba(0, 240, 255, 0.1); }
|
||||
50% { border-color: var(--nexus-neon, #00f0ff); box-shadow: 0 0 25px rgba(0, 240, 255, 0.3); }
|
||||
100% { border-color: rgba(0, 240, 255, 0.3); box-shadow: 0 0 5px rgba(0, 240, 255, 0.1); }
|
||||
0% {
|
||||
border-color: rgba(0, 240, 255, 0.3);
|
||||
box-shadow: 0 0 5px rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-color: var(--nexus-neon, #00f0ff);
|
||||
box-shadow: 0 0 25px rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
100% {
|
||||
border-color: rgba(0, 240, 255, 0.3);
|
||||
box-shadow: 0 0 5px rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Quiz Navigation Header */
|
||||
@@ -479,7 +557,8 @@ main {
|
||||
|
||||
.platform-mobile .reader-pane {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important; /* full viewport height */
|
||||
height: 100vh !important;
|
||||
/* full viewport height */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -506,9 +585,11 @@ main {
|
||||
}
|
||||
|
||||
.platform-mobile .nexus-mobile-reader-tabs {
|
||||
display: none; /* Keep hidden by default */
|
||||
display: none;
|
||||
/* Keep hidden by default */
|
||||
width: 100vw;
|
||||
height: 100vh; /* full viewport height */
|
||||
height: 100vh;
|
||||
/* full viewport height */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -519,7 +600,8 @@ main {
|
||||
|
||||
.app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs,
|
||||
.app-container.platform-mobile.active-mobile-tab-concepts .nexus-mobile-reader-tabs {
|
||||
display: block; /* Show only when graph or concepts tabs are active */
|
||||
display: block;
|
||||
/* Show only when graph or concepts tabs are active */
|
||||
}
|
||||
|
||||
.nexus-mobile-tab-content {
|
||||
@@ -542,6 +624,7 @@ main {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -621,9 +704,18 @@ main {
|
||||
}
|
||||
|
||||
@keyframes quiz-pulse-btn-anim {
|
||||
0% { color: rgba(255, 255, 255, 0.5); }
|
||||
50% { color: #f43f5e; text-shadow: 0 0 8px rgba(244, 63, 94, 0.6); }
|
||||
100% { color: rgba(255, 255, 255, 0.5); }
|
||||
0% {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
50% {
|
||||
color: #f43f5e;
|
||||
text-shadow: 0 0 8px rgba(244, 63, 94, 0.6);
|
||||
}
|
||||
|
||||
100% {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-insight-body {
|
||||
@@ -648,3 +740,279 @@ main {
|
||||
}
|
||||
|
||||
/* Obsolescence managed: consolidated mobile toolbar and sheet styled inside respective components */
|
||||
|
||||
/* Theme-specific Overrides for Light Mode */
|
||||
.app-container.theme-light .intelligence-sidebar {
|
||||
background: #f4f1ea;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: -10px 0 30px rgba(139, 130, 115, 0.05);
|
||||
}
|
||||
|
||||
.app-container.theme-light .resizer {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-container.theme-light .resizer::before {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.app-container.theme-light .resizer::after {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 2px 8px rgba(139, 130, 115, 0.12);
|
||||
}
|
||||
|
||||
.app-container.theme-light .resizer:hover::before,
|
||||
.app-container.theme-light.is-resizing .resizer::before {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.app-container.theme-light .resizer:hover::after,
|
||||
.app-container.theme-light.is-resizing .resizer::after {
|
||||
background: #10b981;
|
||||
width: 6px;
|
||||
height: 80px;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.app-container.theme-light .intelligence-header {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.app-container.theme-light .close-btn {
|
||||
color: #878378;
|
||||
}
|
||||
|
||||
.app-container.theme-light .close-btn:hover {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.app-container.theme-light .visual-workspace {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.app-container.theme-light .contextual-intelligence-panel {
|
||||
background: #f4f1ea;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.app-container.theme-light .panel-header {
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.app-container.theme-light .panel-title {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.app-container.theme-light .no-node-selected {
|
||||
color: #878378;
|
||||
}
|
||||
|
||||
.app-container.theme-light .placeholder-glow {
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.15) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.app-container.theme-light .node-header-section {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.app-container.theme-light .node-label {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.app-container.theme-light .node-details .section-title {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.app-container.theme-light .neon-sub-header {
|
||||
border-left: 2px solid #10b981;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.app-container.theme-light .node-description {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.app-container.theme-light .node-summary {
|
||||
color: #44403c;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-left: 2px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-container.theme-light .key-term-item {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.app-container.theme-light .term-bullet {
|
||||
color: #10b981;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.app-container.theme-light .sidebar-footer {
|
||||
background: #f4f1ea;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.app-container.theme-light .open-quiz-btn {
|
||||
background: rgba(16, 185, 129, 0.03);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.app-container.theme-light .open-quiz-btn:hover {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.app-container.theme-light .quiz-pulse-btn {
|
||||
animation: quiz-pulse-btn-light 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes quiz-pulse-btn-light {
|
||||
0% {
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
box-shadow: 0 0 5px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
100% {
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
box-shadow: 0 0 5px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.app-container.theme-light .quiz-nav {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.app-container.theme-light .back-to-graph-btn {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.app-container.theme-light .back-to-graph-btn:hover {
|
||||
color: #10b981;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.app-container.theme-light .mobile-insight-body {
|
||||
background: #f4f1ea;
|
||||
}
|
||||
|
||||
.app-container.theme-light .mobile-insight-header {
|
||||
background: #f4f1ea;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.app-container.theme-light .mobile-insight-nav {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.app-container.theme-light .mobile-insight-nav-btn {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.app-container.theme-light .mobile-insight-nav-btn.active {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.app-container.theme-light .skeleton-line {
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.03) 25%, rgba(0, 0, 0, 0.08) 50%, rgba(0, 0, 0, 0.03) 75%);
|
||||
}
|
||||
|
||||
.app-container.theme-light .clear-summary-btn {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.app-container.theme-light .clear-summary-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
/* Skeleton Loader for Selection Summary */
|
||||
.skeleton-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 0.75rem;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite linear;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
height: 1.25rem;
|
||||
width: 60%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-line.w-90 {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.skeleton-line.w-80 {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-line.w-70 {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.skeleton-line.w-60 {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-badge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Clear Summary Button styling */
|
||||
.clear-summary-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-summary-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public enum MobileReaderTab
|
||||
/// <summary>
|
||||
/// Screen coordinates for text selection popup positioning.
|
||||
/// </summary>
|
||||
public record SelectionCoordinates(double Top, double Left, double Width);
|
||||
public record SelectionCoordinates(double Top, double Left, double Width, double Height, double Bottom, double ViewportWidth);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a message in the KM-RAG global and mobile intelligence chat threads.
|
||||
@@ -28,6 +28,12 @@ public class ChatMessage
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public List<ResponseSegment> Segments { get; set; } = new();
|
||||
public List<CitationDto> Citations { get; set; } = new();
|
||||
|
||||
public string ClearText { get; set; } = string.Empty;
|
||||
public string BlurredTeaserText { get; set; } = string.Empty;
|
||||
public bool IsPaywalled { get; set; }
|
||||
public string SourceBookTitle { get; set; } = string.Empty;
|
||||
public string DocumentId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<span>lub</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
|
||||
<EditForm FormName="login-form" Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="field-group">
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<form @formname="hidden-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<input type="hidden" name="email" value="@_loginModel.Email" />
|
||||
<input type="hidden" name="password" value="@_loginModel.Password" />
|
||||
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
|
||||
@@ -117,7 +117,10 @@
|
||||
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
private LoginModel _loginModel = new();
|
||||
#pragma warning disable BL0008
|
||||
[SupplyParameterFromForm(FormName = "login-form")]
|
||||
private LoginModel _loginModel { get; set; } = new();
|
||||
#pragma warning restore BL0008
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
private bool _showPassword;
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@attribute [Authorize]
|
||||
@using NexusReader.Domain.Enums
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IThemeService ThemeService
|
||||
|
||||
<div class="profile-page-container">
|
||||
<div class="background-radial"></div>
|
||||
@@ -41,7 +43,7 @@
|
||||
<!-- Intelligence Card -->
|
||||
<div class="metric-card glass-panel">
|
||||
<div class="card-header">
|
||||
<NexusIcon Name="robot" Size="24" Color="var(--nexus-neon)" />
|
||||
<NexusIcon Name="robot" Size="24" Color="#10b981" />
|
||||
<h3>Interfejs AI</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -62,7 +64,7 @@
|
||||
<!-- Sync Card -->
|
||||
<div class="metric-card glass-panel">
|
||||
<div class="card-header">
|
||||
<NexusIcon Name="activity" Size="24" Color="var(--nexus-neon)" />
|
||||
<NexusIcon Name="activity" Size="24" Color="#10b981" />
|
||||
<h3>Wydajność Nauki</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -80,7 +82,7 @@
|
||||
<!-- Account Status Card -->
|
||||
<div class="metric-card glass-panel full-width">
|
||||
<div class="card-header">
|
||||
<NexusIcon Name="shield" Size="24" Color="var(--nexus-neon)" />
|
||||
<NexusIcon Name="shield" Size="24" Color="#10b981" />
|
||||
<h3>Status Autoryzacji</h3>
|
||||
</div>
|
||||
<div class="card-body status-layout">
|
||||
@@ -97,6 +99,31 @@
|
||||
</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>
|
||||
}
|
||||
@@ -110,6 +137,7 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await ThemeService.InitializeAsync();
|
||||
var result = await IdentityService.GetProfileAsync();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
@@ -118,6 +146,12 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ChangeTheme(ThemeMode mode)
|
||||
{
|
||||
await ThemeService.SetThemeAsync(mode);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private int CalculateProgress()
|
||||
{
|
||||
if (_profile == null || _profile.AITokenLimit == 0) return 0;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background-color: #0a0c10;
|
||||
color: #e0e6ed;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-main);
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -18,7 +18,7 @@
|
||||
transform: translate(-50%, -50%);
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
background: radial-gradient(circle, rgba(0, 255, 153, 0.05) 0%, transparent 70%);
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.03) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
.mesh-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.02) 1px, transparent 0);
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -63,17 +63,17 @@
|
||||
.avatar-inner {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #151921;
|
||||
border: 2px solid var(--nexus-neon);
|
||||
background: var(--bg-surface);
|
||||
border: 2px solid #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--nexus-neon);
|
||||
color: #10b981;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 153, 0.2), inset 0 0 20px rgba(0, 255, 153, 0.1);
|
||||
box-shadow: 0 0 30px rgba(16, 185, 129, 0.2), inset 0 0 20px rgba(16, 185, 129, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
position: absolute;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border: 1px solid rgba(0, 255, 153, 0.3);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 50%;
|
||||
animation: pulse-ring 3s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
@@ -98,13 +98,13 @@
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.system-rank {
|
||||
font-family: 'Inter', 'Courier New', Courier, monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--nexus-neon);
|
||||
color: #10b981;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
opacity: 0.8;
|
||||
@@ -120,11 +120,17 @@
|
||||
|
||||
.glass-panel {
|
||||
padding: 32px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(0, 255, 153, 0.2);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
@@ -148,7 +154,7 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #a0aec0;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -171,28 +177,28 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: #fff; line-height: 1; }
|
||||
.usage-values .separator { font-size: 1.2rem; color: #4a5568; }
|
||||
.usage-values .total { font-size: 1.2rem; color: #718096; font-weight: 600; }
|
||||
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: var(--text-main); line-height: 1; }
|
||||
.usage-values .separator { font-size: 1.2rem; color: var(--border); }
|
||||
.usage-values .total { font-size: 1.2rem; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
.usage-progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--nexus-neon);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.5);
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -206,13 +212,13 @@
|
||||
.score-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--nexus-neon);
|
||||
color: #10b981;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -223,10 +229,11 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(0, 255, 153, 0.05);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e0;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.truncate {
|
||||
@@ -259,10 +266,16 @@
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.plan-badge.pro {
|
||||
background: rgba(0, 255, 153, 0.1);
|
||||
color: var(--nexus-neon);
|
||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
||||
.plan-badge.pro, .plan-badge.enterprise {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.plan-badge.free {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tenant-tag {
|
||||
@@ -318,11 +331,11 @@
|
||||
.nexus-loader {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 4px solid rgba(0, 255, 153, 0.1);
|
||||
border-top-color: var(--nexus-neon);
|
||||
border: 4px solid rgba(16, 185, 129, 0.1);
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
||||
filter: drop-shadow(0 0 10px #10b981);
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -335,3 +348,111 @@
|
||||
.btn-nexus { width: 100%; justify-content: center; }
|
||||
.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>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
|
||||
<EditForm FormName="register-form" Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="field-group">
|
||||
@@ -71,14 +71,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<form @formname="hidden-register-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<input type="hidden" name="email" value="@_registerModel.Email" />
|
||||
<input type="hidden" name="password" value="@_registerModel.Password" />
|
||||
<input type="hidden" name="rememberMe" value="false" />
|
||||
</form>
|
||||
|
||||
@code {
|
||||
private RegisterModel _registerModel = new();
|
||||
#pragma warning disable BL0008
|
||||
[SupplyParameterFromForm(FormName = "register-form")]
|
||||
private RegisterModel _registerModel { get; set; } = new();
|
||||
#pragma warning restore BL0008
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
@page "/catalog"
|
||||
@attribute [Authorize]
|
||||
@implements IDisposable
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject IReaderNavigationService ReaderNavigation
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<Catalog> Logger
|
||||
|
||||
<div class="catalog-page">
|
||||
<header class="catalog-header">
|
||||
<div class="header-title-section">
|
||||
<h1>Katalog Kursów</h1>
|
||||
<p class="subtitle">Rozwijaj swoje kompetencje techniczne z interaktywnymi kursami zintegrowanymi z asystentem Nexus AI</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="catalog-content">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="catalog-loading-container">
|
||||
<div class="loader-card">
|
||||
<div class="spinner-glow small"></div>
|
||||
<span class="loader-text">Wczytywanie katalogu...</span>
|
||||
</div>
|
||||
|
||||
<div class="loading-grid">
|
||||
@for (int i = 0; i < 3; i++)
|
||||
{
|
||||
<div class="skeleton-card">
|
||||
<div class="skeleton-cover"></div>
|
||||
<div class="skeleton-details">
|
||||
<div class="skeleton-line title"></div>
|
||||
<div class="skeleton-line author"></div>
|
||||
<div class="skeleton-line button"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="catalog-grid">
|
||||
@* Render real books first *@
|
||||
@if (_books != null && _books.Any())
|
||||
{
|
||||
@foreach (var book in _books)
|
||||
{
|
||||
<div class="course-card" @onclick="() => OpenBook(book.Id)">
|
||||
<div class="card-cover-container">
|
||||
<img src="@(book.CoverUrl ?? "https://api.dicebear.com/7.x/identicon/svg?seed=" + book.Title)" alt="@book.Title" class="card-cover" />
|
||||
<div class="cover-overlay">
|
||||
<span class="start-action">Uruchom kurs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-details">
|
||||
<span class="category-badge">E-Book</span>
|
||||
<h3 class="course-title" title="@book.Title">@book.Title</h3>
|
||||
<p class="course-author">Autor: @book.Author.Name</p>
|
||||
<p class="course-desc">
|
||||
@(string.IsNullOrEmpty(book.Description) ? "Rozpocznij naukę i buduj strukturę pojęć w oparciu o autorskie algorytmy ekstrakcji wiedzy Nexus AI." : book.Description)
|
||||
</p>
|
||||
|
||||
<div class="card-footer-info">
|
||||
<span class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||
Rozdziały: Wiele
|
||||
</span>
|
||||
<span class="meta-item text-success">Dostępny</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn-nexus start-course-btn" @onclick="() => OpenBook(book.Id)" @onclick:stopPropagation="true">
|
||||
ZACZNIJ KURS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* Curated Showcase Mock Courses to look extremely premium *@
|
||||
<div class="course-card">
|
||||
<div class="card-cover-container">
|
||||
<div class="mock-cover dotnet-gradient">
|
||||
<span class="cover-code-text"><.NET 10></span>
|
||||
</div>
|
||||
<div class="cover-overlay">
|
||||
<span class="start-action">Zarejestruj się</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-details">
|
||||
<span class="category-badge architecture">Architektura</span>
|
||||
<h3 class="course-title">.NET 10 & Blazor SaaS Architecture</h3>
|
||||
<p class="course-author">Autor: Nexus Architect</p>
|
||||
<p class="course-desc">
|
||||
Zaawansowany kurs budowania skalowalnych SaaS z Native AOT, CQRS, MediatR, FluentResults i izolowanym systemem stylów Blazor CSS.
|
||||
</p>
|
||||
|
||||
<div class="card-footer-info">
|
||||
<span class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
12 Modułów
|
||||
</span>
|
||||
<span class="meta-item text-premium">Premium</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn-nexus start-course-btn mock-btn" @onclick="ShowPremiumAlert">
|
||||
ZACZNIJ KURS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-card">
|
||||
<div class="card-cover-container">
|
||||
<div class="mock-cover blazor-gradient">
|
||||
<span class="cover-code-text">BLAZOR</span>
|
||||
</div>
|
||||
<div class="cover-overlay">
|
||||
<span class="start-action">Zarejestruj się</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-details">
|
||||
<span class="category-badge design">Performance</span>
|
||||
<h3 class="course-title">Blazor State & Rendering Masterclass</h3>
|
||||
<p class="course-author">Autor: Nexus Architect</p>
|
||||
<p class="course-desc">
|
||||
Techniki optymalizacji renderowania, zarządzanie stanem w aplikacjach rozproszonych oraz głęboka integracja JavaScript Interop.
|
||||
</p>
|
||||
|
||||
<div class="card-footer-info">
|
||||
<span class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
8 Modułów
|
||||
</span>
|
||||
<span class="meta-item text-premium">Premium</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn-nexus start-course-btn mock-btn" @onclick="ShowPremiumAlert">
|
||||
ZACZNIJ KURS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-card">
|
||||
<div class="card-cover-container">
|
||||
<div class="mock-cover graph-gradient">
|
||||
<span class="cover-code-text">D3.JS GRAPH</span>
|
||||
</div>
|
||||
<div class="cover-overlay">
|
||||
<span class="start-action">Zarejestruj się</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-details">
|
||||
<span class="category-badge analytics">Wizualizacja</span>
|
||||
<h3 class="course-title">D3.js & interactive Knowledge Graphs</h3>
|
||||
<p class="course-author">Autor: Nexus Architect</p>
|
||||
<p class="course-desc">
|
||||
Projektowanie interaktywnych grafów pojęć i dynamicznych map myśli 2D/3D zsynchronizowanych z modelem językowym AI.
|
||||
</p>
|
||||
|
||||
<div class="card-footer-info">
|
||||
<span class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
10 Modułów
|
||||
</span>
|
||||
<span class="meta-item text-premium">Premium</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn-nexus start-course-btn mock-btn" @onclick="ShowPremiumAlert">
|
||||
ZACZNIJ KURS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _isLoading = true;
|
||||
private List<LastReadBookDto>? _books;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged += HandleBooksChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await LoadBooksAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBooksChanged()
|
||||
{
|
||||
_ = InvokeAsync(LoadBooksAsync);
|
||||
}
|
||||
|
||||
private async Task LoadBooksAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
_books = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
|
||||
_isLoading = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[Catalog] Failed to load books.");
|
||||
if (OperatingSystem.IsBrowser())
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenBook(Guid bookId)
|
||||
{
|
||||
ReaderNavigation.NavigateToBook(bookId);
|
||||
}
|
||||
|
||||
private void ShowPremiumAlert()
|
||||
{
|
||||
// Showcase callback
|
||||
NavigationManager.NavigateTo("/profile");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
.catalog-page {
|
||||
padding: 3rem 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
.catalog-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.catalog-header h1 {
|
||||
font-family: var(--nexus-font-serif);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.catalog-header .subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Catalog Grid */
|
||||
.catalog-grid, .loading-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.course-card {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.course-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card-cover-container {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.course-card:hover .card-cover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* Gradients for Mock Course Covers */
|
||||
.mock-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cover-code-text {
|
||||
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dotnet-gradient {
|
||||
background: linear-gradient(135deg, #512bd4 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
.blazor-gradient {
|
||||
background: linear-gradient(135deg, #f05a28 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
.graph-gradient {
|
||||
background: linear-gradient(135deg, #0d9488 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
.cover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(13, 13, 15, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.course-card:hover .cover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.start-action {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 30px;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.course-card:hover .start-action {
|
||||
transform: translateY(0);
|
||||
background: #ffffff;
|
||||
color: #121214;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
align-self: flex-start;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category-badge.architecture {
|
||||
color: #f43f5e;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
}
|
||||
|
||||
.category-badge.design {
|
||||
color: #0ea5e9;
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.category-badge.analytics {
|
||||
color: #a855f7;
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.4rem 0;
|
||||
color: var(--text-main);
|
||||
line-height: 1.3;
|
||||
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
|
||||
}
|
||||
|
||||
.course-author {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.course-desc {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1.5rem 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-footer-info {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-premium {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.start-course-btn {
|
||||
width: 100%;
|
||||
background: #10b981;
|
||||
color: #0d0d0d;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.start-course-btn:hover {
|
||||
background: #059669;
|
||||
color: #ffffff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Skeleton Loading */
|
||||
.skeleton-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
height: 440px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeleton-cover {
|
||||
height: 200px;
|
||||
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-details {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
height: 20px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-line.author {
|
||||
height: 14px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.skeleton-line.button {
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.catalog-loading-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.catalog-loading-container .loader-card {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 2.25rem;
|
||||
border-radius: 40px;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border);
|
||||
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.spinner-glow {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid rgba(16, 185, 129, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: #10b981;
|
||||
animation: spin 1s cubic-bezier(0.55, 0.055, 0.675, 0.19) infinite;
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.loader-text {
|
||||
font-weight: 500;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(15px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<NexusIcon Name="book-open" Size="64" Class="dim-icon" />
|
||||
<h2>Brak Aktywnych Książek</h2>
|
||||
<p>Nie wybrano żadnej książki lub ta książka nie ma jeszcze wygenerowanej mapy pojęć przez Nexus AI.</p>
|
||||
<a href="/library" class="btn-nexus btn-nexus-primary">Przejdź do Biblioteki</a>
|
||||
<a href="/my-books" class="btn-nexus btn-nexus-primary">Przejdź do Moich Książek</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -53,7 +53,7 @@
|
||||
<div class="header-back">
|
||||
<button class="btn-nexus btn-nexus-secondary btn-back" @onclick="GoBackToLibrary">
|
||||
<NexusIcon Name="arrow-left" Size="16" />
|
||||
<span>Biblioteka</span>
|
||||
<span>Moje Książki</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-title">
|
||||
@@ -257,7 +257,7 @@
|
||||
|
||||
private void GoBackToLibrary()
|
||||
{
|
||||
NavigationManager.NavigateTo("/library");
|
||||
NavigationManager.NavigateTo("/my-books");
|
||||
}
|
||||
|
||||
private void GoToReader()
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 1.25rem 2rem;
|
||||
background: rgba(20, 20, 20, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.header-back .btn-back {
|
||||
@@ -30,22 +30,22 @@
|
||||
}
|
||||
|
||||
.header-back .btn-back:hover {
|
||||
border-color: var(--nexus-neon);
|
||||
color: var(--nexus-neon);
|
||||
background: var(--nexus-primary-glow);
|
||||
box-shadow: 0 0 10px var(--nexus-primary-glow);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.header-title .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-actions .btn-action {
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.header-actions .btn-action:hover {
|
||||
box-shadow: 0 0 20px var(--nexus-primary-glow);
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Grid Layout */
|
||||
@@ -73,28 +73,26 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl, 16px);
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pane-content {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Loading, Error and Empty States */
|
||||
.loading-state, .error-state, .empty-dashboard-state {
|
||||
display: flex;
|
||||
@@ -118,15 +116,15 @@
|
||||
}
|
||||
|
||||
.neon-pulse {
|
||||
color: var(--nexus-neon);
|
||||
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
||||
color: var(--accent);
|
||||
filter: drop-shadow(0 0 10px var(--accent-glow));
|
||||
animation: robot-pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes robot-pulse {
|
||||
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(--nexus-neon)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--accent-glow)); }
|
||||
50% { transform: scale(1.08); filter: drop-shadow(0 0 25px var(--accent-glow)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--accent-glow)); }
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
@@ -135,8 +133,8 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--nexus-neon);
|
||||
box-shadow: 0 0 15px var(--nexus-neon);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 15px var(--accent);
|
||||
animation: scan 2s infinite linear;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -149,7 +147,7 @@
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-muted);
|
||||
margin-top: 1rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -164,17 +162,18 @@
|
||||
}
|
||||
|
||||
.dim-icon {
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-dashboard-state h2, .error-state h3 {
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-dashboard-state p, .error-state p {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 2rem 0;
|
||||
@@ -189,25 +188,25 @@
|
||||
flex-grow: 1;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-glowing-brain {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 255, 153, 0.04);
|
||||
border: 1px solid rgba(0, 255, 153, 0.15);
|
||||
background: var(--accent-glow);
|
||||
border: 1px solid var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 0 20px var(--nexus-primary-glow);
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.workspace-empty h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -227,7 +226,7 @@
|
||||
|
||||
.workspace-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
@@ -242,7 +241,7 @@
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nexus-neon);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -256,22 +255,22 @@
|
||||
}
|
||||
|
||||
.badge-unlocked {
|
||||
background: rgba(0, 255, 153, 0.08);
|
||||
color: var(--nexus-neon);
|
||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
||||
background: var(--accent-glow);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.badge-locked {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.workspace-body {
|
||||
@@ -291,22 +290,22 @@
|
||||
background: transparent;
|
||||
}
|
||||
.workspace-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.workspace-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--nexus-neon);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.locked-warning {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 171, 0, 0.04);
|
||||
border: 1px solid rgba(255, 171, 0, 0.15);
|
||||
background: rgba(217, 119, 6, 0.05);
|
||||
border: 1px solid rgba(217, 119, 6, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.lock-warning-icon {
|
||||
@@ -326,14 +325,14 @@
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metadata-section h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
@@ -345,12 +344,12 @@
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 3px solid var(--nexus-neon);
|
||||
background: var(--bg-base);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
@@ -365,9 +364,9 @@
|
||||
|
||||
.term-pill {
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
@@ -375,14 +374,14 @@
|
||||
}
|
||||
|
||||
.term-pill:hover {
|
||||
border-color: rgba(0, 255, 153, 0.2);
|
||||
color: var(--nexus-neon);
|
||||
background: rgba(0, 255, 153, 0.03);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
}
|
||||
|
||||
.workspace-footer {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -403,3 +402,68 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user