Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f79eb0b2e | |||
| 4432c901f0 | |||
| c94e8f0acb | |||
| ec3fc52a73 | |||
| 9fddafa423 | |||
| 9291bde531 |
@@ -4,6 +4,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||||
|
<PackageVersion Include="Markdig" Version="0.38.0" />
|
||||||
<PackageVersion Include="Mapster" Version="10.0.7" />
|
<PackageVersion Include="Mapster" Version="10.0.7" />
|
||||||
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||||
<PackageVersion Include="MediatR" Version="12.1.1" />
|
<PackageVersion Include="MediatR" Version="12.1.1" />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
|
|||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
|||||||
@@ -46,4 +46,9 @@ version: 1.0
|
|||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Git Workflow & Integration**
|
> **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`).
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
- ASPNETCORE_ENVIRONMENT=Staging
|
- ASPNETCORE_ENVIRONMENT=Staging
|
||||||
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
||||||
|
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
|
||||||
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||||
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
||||||
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
|||||||
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,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,23 @@ namespace NexusReader.Application.Common;
|
|||||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
||||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
|
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
|
||||||
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.LocalBackupEnvelope))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.AutosaveChapterRequest))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.CreateBookCommand))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookRequestDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookResponseDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorDashboardDataDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.DashboardMetricsDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto))]
|
||||||
|
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookDto>))]
|
||||||
|
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto>))]
|
||||||
public partial class AppJsonContext : JsonSerializerContext
|
public partial class AppJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Application.Constants;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
namespace NexusReader.Application.DTOs.User;
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ public record UserProfileDto
|
|||||||
public string UserId { get; init; } = string.Empty;
|
public string UserId { get; init; } = string.Empty;
|
||||||
public int AITokensUsed { get; init; }
|
public int AITokensUsed { get; init; }
|
||||||
public Guid TenantId { get; init; }
|
public Guid TenantId { get; init; }
|
||||||
|
public ThemeMode ThemePreference { get; init; } = ThemeMode.System;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relational data for the current subscription plan.
|
/// Relational data for the current subscription plan.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
UserId = u.Id,
|
UserId = u.Id,
|
||||||
AITokensUsed = u.AITokensUsed,
|
AITokensUsed = u.AITokensUsed,
|
||||||
TenantIdString = u.TenantId,
|
TenantIdString = u.TenantId,
|
||||||
|
ThemePreference = u.ThemePreference,
|
||||||
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||||
{
|
{
|
||||||
Id = u.SubscriptionPlan.Id,
|
Id = u.SubscriptionPlan.Id,
|
||||||
@@ -106,6 +107,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
AverageQuizScore = averageQuizScore,
|
AverageQuizScore = averageQuizScore,
|
||||||
DisplayName = userRaw.DisplayName,
|
DisplayName = userRaw.DisplayName,
|
||||||
BooksReadCount = userRaw.BooksReadCount,
|
BooksReadCount = userRaw.BooksReadCount,
|
||||||
|
ThemePreference = userRaw.ThemePreference,
|
||||||
ConceptsMappedCount = conceptsMappedCount,
|
ConceptsMappedCount = conceptsMappedCount,
|
||||||
LastReadBook = userRaw.LastReadBook,
|
LastReadBook = userRaw.LastReadBook,
|
||||||
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
|
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
|
||||||
|
|||||||
+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 Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
using Pgvector;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
@@ -174,6 +172,103 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.ToTable("Authors");
|
b.ToTable("Authors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CurrentDraftRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LivePublishedRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CurrentDraftRevisionId");
|
||||||
|
|
||||||
|
b.HasIndex("LivePublishedRevisionId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("BookId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PublishedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("VersionString")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.ToTable("BookRevisions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("BookRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("MarkdownContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BookRevisionId");
|
||||||
|
|
||||||
|
b.ToTable("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -264,9 +359,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.Property<int>("Type")
|
b.Property<int>("Type")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Vector>("Vector")
|
|
||||||
.HasColumnType("vector(768)");
|
|
||||||
|
|
||||||
b.Property<string>("Version")
|
b.Property<string>("Version")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
@@ -388,6 +480,11 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("ThemePreference")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -480,9 +577,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<Vector>("Vector")
|
|
||||||
.HasColumnType("vector(1536)");
|
|
||||||
|
|
||||||
b.HasKey("ContentHash");
|
b.HasKey("ContentHash");
|
||||||
|
|
||||||
b.HasIndex("ContentHash")
|
b.HasIndex("ContentHash")
|
||||||
@@ -617,6 +711,53 @@ namespace NexusReader.Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CurrentDraftRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LivePublishedRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CurrentDraftRevision");
|
||||||
|
|
||||||
|
b.Navigation("LivePublishedRevision");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
||||||
|
.WithMany("Revisions")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
||||||
|
.WithMany("Chapters")
|
||||||
|
.HasForeignKey("BookRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BookRevision");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||||
@@ -692,6 +833,16 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.Navigation("Ebooks");
|
b.Navigation("Ebooks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Revisions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("IncomingLinks");
|
b.Navigation("IncomingLinks");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
namespace NexusReader.Data.Persistence;
|
namespace NexusReader.Data.Persistence;
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||||
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||||
public DbSet<Author> Authors => Set<Author>();
|
public DbSet<Author> Authors => Set<Author>();
|
||||||
|
public DbSet<Book> Books => Set<Book>();
|
||||||
|
public DbSet<BookRevision> BookRevisions => Set<BookRevision>();
|
||||||
|
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -43,6 +47,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
||||||
entity.Property(u => u.SubscriptionPlanId)
|
entity.Property(u => u.SubscriptionPlanId)
|
||||||
.HasDefaultValue(1);
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
entity.Property(u => u.ThemePreference)
|
||||||
|
.HasConversion<int>()
|
||||||
|
.HasDefaultValue(ThemeMode.System);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||||
@@ -109,6 +117,48 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
entity.HasIndex(e => e.TenantId);
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Book>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(b => b.Id);
|
||||||
|
entity.HasIndex(b => b.TenantId);
|
||||||
|
entity.HasIndex(b => b.UserId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(b => b.Revisions)
|
||||||
|
.WithOne(r => r.Book)
|
||||||
|
.HasForeignKey(r => r.BookId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(b => b.CurrentDraftRevision)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(b => b.CurrentDraftRevisionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
entity.HasOne(b => b.LivePublishedRevision)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(b => b.LivePublishedRevisionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<BookRevision>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
|
||||||
|
entity.HasMany(r => r.Chapters)
|
||||||
|
.WithOne(c => c.BookRevision)
|
||||||
|
.HasForeignKey(c => c.BookRevisionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Chapter>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(c => c.Id);
|
||||||
|
});
|
||||||
|
|
||||||
// Seed Subscription Plans with deterministic IDs
|
// Seed Subscription Plans with deterministic IDs
|
||||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
||||||
|
|||||||
@@ -136,6 +136,72 @@ public static class DbInitializer
|
|||||||
{
|
{
|
||||||
Console.WriteLine("[Seeder] Admin user already exists.");
|
Console.WriteLine("[Seeder] Admin user already exists.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed Sample Authored Book for Creator Dashboard
|
||||||
|
var activeAdmin = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
|
||||||
|
if (activeAdmin != null)
|
||||||
|
{
|
||||||
|
if (!dbContext.Books.Any(b => b.UserId == activeAdmin.Id))
|
||||||
|
{
|
||||||
|
var sampleBookId = Guid.NewGuid();
|
||||||
|
var sampleBook = new Book
|
||||||
|
{
|
||||||
|
Id = sampleBookId,
|
||||||
|
Title = "Przewodnik po platformie Nexus",
|
||||||
|
UserId = activeAdmin.Id,
|
||||||
|
TenantId = activeAdmin.TenantId ?? "global"
|
||||||
|
};
|
||||||
|
dbContext.Books.Add(sampleBook);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sampleRevisionId = Guid.NewGuid();
|
||||||
|
var sampleRevision = new BookRevision
|
||||||
|
{
|
||||||
|
Id = sampleRevisionId,
|
||||||
|
BookId = sampleBookId,
|
||||||
|
VersionString = "Working Draft",
|
||||||
|
IsPublished = false,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
dbContext.BookRevisions.Add(sampleRevision);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sampleChapter1 = new Chapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookRevisionId = sampleRevisionId,
|
||||||
|
Title = "Rozdział 1: Wprowadzenie do Zen Mode",
|
||||||
|
MarkdownContent = @"# Zen Mode Editor
|
||||||
|
|
||||||
|
Welcome to your dedicated workspace. This premium panel supports Notion-like WYSIWYG editing.
|
||||||
|
|
||||||
|
## Features:
|
||||||
|
- **Zero Distraction**: Simple elevation and border framing.
|
||||||
|
- **GFM Tables**: Consistent cell padding and hover striping.
|
||||||
|
- **Clean Code Blocks**: Pre-rendered base64 font-loaded code-preview blocks.",
|
||||||
|
SortOrder = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var sampleChapter2 = new Chapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookRevisionId = sampleRevisionId,
|
||||||
|
Title = "Rozdział 2: Zabezpieczenia i XSS",
|
||||||
|
MarkdownContent = @"# Security Overview
|
||||||
|
|
||||||
|
This module provides Magic Number image signature checking and HtmlSanitizer filters.",
|
||||||
|
SortOrder = 2
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.Chapters.Add(sampleChapter1);
|
||||||
|
dbContext.Chapters.Add(sampleChapter2);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
sampleBook.CurrentDraftRevisionId = sampleRevisionId;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
Console.WriteLine("[Seeder] Sample authored book and chapters seeded for admin.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 Microsoft.AspNetCore.Identity;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
@@ -65,4 +66,9 @@ public class NexusUser : IdentityUser
|
|||||||
/// Last read timestamp.
|
/// Last read timestamp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? LastReadAt { get; set; }
|
public DateTime? LastReadAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User's visual theme preference.
|
||||||
|
/// </summary>
|
||||||
|
public ThemeMode ThemePreference { get; set; } = ThemeMode.System;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -55,7 +55,15 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Qdrant Client registration
|
// Qdrant Client registration
|
||||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||||
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)
|
// Neo4j Driver registration (supports optional authentication)
|
||||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||||
@@ -78,6 +86,7 @@ public static class DependencyInjection
|
|||||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||||
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
||||||
|
services.Configure<HtmlSanitizerSettings>(configuration.GetSection(HtmlSanitizerSettings.SectionName));
|
||||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||||
@@ -124,6 +133,8 @@ public static class DependencyInjection
|
|||||||
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
||||||
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
||||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||||
|
services.AddScoped<IStorageService, LocalStorageService>();
|
||||||
|
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
|
||||||
|
|
||||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
<PackageReference Include="Polly" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" />
|
<PackageReference Include="Polly.Extensions.Http" />
|
||||||
|
<PackageReference Include="HtmlSanitizer" />
|
||||||
|
<PackageReference Include="Markdig" />
|
||||||
<PackageReference Include="Qdrant.Client" />
|
<PackageReference Include="Qdrant.Client" />
|
||||||
<PackageReference Include="Stripe.net" />
|
<PackageReference Include="Stripe.net" />
|
||||||
<PackageReference Include="VersOne.Epub" />
|
<PackageReference Include="VersOne.Epub" />
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class BookStorageService : IBookStorageService
|
|||||||
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
||||||
EnsureDirectoryExists(uploadsFolder);
|
EnsureDirectoryExists(uploadsFolder);
|
||||||
|
|
||||||
|
fileName = SanitizeFileName(fileName);
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||||
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ public class BookStorageService : IBookStorageService
|
|||||||
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
||||||
EnsureDirectoryExists(coversFolder);
|
EnsureDirectoryExists(coversFolder);
|
||||||
|
|
||||||
|
fileName = SanitizeFileName(fileName);
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||||
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
||||||
|
|
||||||
@@ -63,6 +65,25 @@ public class BookStorageService : IBookStorageService
|
|||||||
return $"covers/{uniqueFileName}";
|
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)
|
private void EnsureDirectoryExists(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class EpubReaderService : IEpubReader
|
|||||||
private readonly ILogger<EpubReaderService> _logger;
|
private readonly ILogger<EpubReaderService> _logger;
|
||||||
private const int WordThreshold = 1000;
|
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 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 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);
|
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 ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", 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 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(
|
public EpubReaderService(
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
@@ -102,7 +105,7 @@ public class EpubReaderService : IEpubReader
|
|||||||
foreach (var p in paragraphs)
|
foreach (var p in paragraphs)
|
||||||
{
|
{
|
||||||
var sanitizedContent = SanitizeParagraph(p);
|
var sanitizedContent = SanitizeParagraph(p);
|
||||||
if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
|
if (string.IsNullOrWhiteSpace(sanitizedContent) || EmptyBlockRegex.IsMatch(sanitizedContent)) continue;
|
||||||
|
|
||||||
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
||||||
|
|
||||||
@@ -236,7 +239,9 @@ public class EpubReaderService : IEpubReader
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(html)) return html;
|
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;
|
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)
|
private static string ResolveRelativePath(string basePath, string relativePath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 MediatR;
|
||||||
using NexusReader.Maui.Infrastructure.Logging;
|
using NexusReader.Maui.Infrastructure.Logging;
|
||||||
using NexusReader.Maui.Infrastructure.Identity;
|
using NexusReader.Maui.Infrastructure.Identity;
|
||||||
|
using NexusReader.Maui.Services;
|
||||||
|
|
||||||
namespace NexusReader.Maui;
|
namespace NexusReader.Maui;
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ public static class MauiProgram
|
|||||||
|
|
||||||
// Minimal Infrastructure
|
// Minimal Infrastructure
|
||||||
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
||||||
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
|
builder.Services.AddSingleton<INativeStorageService, NexusReader.Infrastructure.Mobile.Services.MauiStorageService>();
|
||||||
|
|
||||||
// Minimal Identity (Safe Mode)
|
// Minimal Identity (Safe Mode)
|
||||||
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
||||||
@@ -67,7 +68,8 @@ public static class MauiProgram
|
|||||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||||
builder.Services.AddSingleton(featureSettings);
|
builder.Services.AddSingleton(featureSettings);
|
||||||
|
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddSingleton<IUserPreferenceStore, MauiUserPreferenceStore>();
|
||||||
|
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,28 @@
|
|||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const savedTheme = localStorage.getItem('theme');
|
try {
|
||||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
var themeMode = localStorage.getItem('theme-mode');
|
||||||
const isLight = savedTheme === 'light' || (!savedTheme && !systemPrefersDark);
|
var savedTheme = localStorage.getItem('theme');
|
||||||
if (isLight) {
|
var isLight = false;
|
||||||
document.documentElement.classList.add('theme-light');
|
|
||||||
} else {
|
if (themeMode === '2' || savedTheme === 'light') {
|
||||||
document.documentElement.classList.remove('theme-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>
|
</script>
|
||||||
|
|||||||
@@ -101,6 +101,11 @@
|
|||||||
<line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
|
case "edit":
|
||||||
|
case "edit-2":
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
break;
|
||||||
case "log-out":
|
case "log-out":
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -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); }
|
||||||
|
}
|
||||||
@@ -30,17 +30,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-row .message-avatar {
|
.user-row .message-avatar {
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%);
|
background: var(--bg-surface);
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
|
box-shadow: 0 0 10px var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-row .message-avatar {
|
.ai-row .message-avatar {
|
||||||
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
|
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
|
||||||
color: #e6fffa;
|
color: #e6fffa;
|
||||||
border: 1px solid rgba(0, 255, 153, 0.4);
|
border: 1px solid var(--accent);
|
||||||
box-shadow: 0 0 10px rgba(0, 255, 153, 0.25);
|
box-shadow: 0 0 10px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
@@ -55,23 +55,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-bubble {
|
.user-bubble {
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
color: #e4e4e7;
|
color: var(--text-main);
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 4px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-bubble {
|
.ai-bubble {
|
||||||
background: rgba(26, 26, 30, 0.6);
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
color: #e2e8f0;
|
color: var(--text-main);
|
||||||
border-top-left-radius: 4px;
|
border-top-left-radius: 4px;
|
||||||
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.paywalled-bubble {
|
.paywalled-bubble {
|
||||||
border-color: rgba(16, 185, 129, 0.15);
|
border-color: var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-header {
|
.message-header {
|
||||||
@@ -109,8 +109,8 @@
|
|||||||
.paywall-teaser {
|
.paywall-teaser {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
-webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
|
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||||
mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
|
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||||
filter: blur(2px);
|
filter: blur(2px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
@@ -119,12 +119,12 @@
|
|||||||
|
|
||||||
/* Upsell Card */
|
/* Upsell Card */
|
||||||
.upsell-card {
|
.upsell-card {
|
||||||
background: #1a1a1e;
|
background: var(--bg-base);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
border: 1px solid var(--accent-glow);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.4);
|
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;
|
animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +141,14 @@
|
|||||||
|
|
||||||
.upsell-header h4 {
|
.upsell-header h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #10b981;
|
color: var(--accent);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upsell-text {
|
.upsell-text {
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: var(--text-main);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin: 0 0 1.25rem 0;
|
margin: 0 0 1.25rem 0;
|
||||||
@@ -177,15 +177,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #10b981;
|
background: var(--accent);
|
||||||
border: none;
|
border: none;
|
||||||
color: #121214;
|
color: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: #0d9668;
|
background: var(--accent);
|
||||||
|
opacity: 0.9;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
|
box-shadow: 0 4px 15px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:active:not(:disabled) {
|
.btn-primary:active:not(:disabled) {
|
||||||
@@ -193,19 +194,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
background: rgba(16, 185, 129, 0.5);
|
background: var(--accent-glow);
|
||||||
color: rgba(18, 18, 20, 0.6);
|
color: var(--text-muted);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #10b981;
|
border: 1px solid var(--accent);
|
||||||
color: #10b981;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: rgba(16, 185, 129, 0.05);
|
background: var(--accent-glow);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +219,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
background: rgba(16, 185, 129, 0.1);
|
background: var(--accent-glow);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
border: 1px solid var(--accent);
|
||||||
color: #10b981;
|
color: var(--accent);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
@@ -238,8 +239,8 @@
|
|||||||
.payment-spinner {
|
.payment-spinner {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border: 2px solid rgba(18, 18, 20, 0.2);
|
border: 2px solid var(--border);
|
||||||
border-top-color: #121214;
|
border-top-color: var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
@@ -265,3 +266,49 @@
|
|||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.theme-light .ai-row .message-avatar {
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .user-row .message-avatar {
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 130, 115, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .upsell-card {
|
||||||
|
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .btn-primary:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
color: #ffffff;
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .btn-secondary {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .btn-secondary:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .paywall-teaser {
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||||
ThemeService.OnThemeChanged += HandleThemeChangedAsync;
|
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleClearCache()
|
private async Task HandleClearCache()
|
||||||
@@ -68,11 +68,11 @@
|
|||||||
|
|
||||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged);
|
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||||
ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,3 +233,126 @@
|
|||||||
.lock-icon {
|
.lock-icon {
|
||||||
color: rgba(255, 255, 255, 0.2);
|
color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.theme-light .concepts-map::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .empty-map-state {
|
||||||
|
background: rgba(0, 0, 0, 0.01);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .empty-map-state .dim-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .timeline-step:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .timeline-step.unlocked:hover {
|
||||||
|
border-color: rgba(16, 185, 129, 0.15);
|
||||||
|
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .timeline-step.selected {
|
||||||
|
background: rgba(16, 185, 129, 0.04);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .node-circle {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .unlocked .node-circle {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .locked .node-circle {
|
||||||
|
background: var(--bg-base);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .node-glow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .track-active {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .track-inactive {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .node-content {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .timeline-step.selected .node-content {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .segment-tag {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .unlocked .segment-tag {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .badge-unlocked {
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .badge-locked {
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .node-title {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .timeline-step.unlocked:hover .node-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .locked .node-title {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .node-desc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .locked .node-desc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .check-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .lock-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
@if (_isLoading)
|
@if (_isLoading)
|
||||||
{
|
{
|
||||||
<div class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
|
<div @key='"loading"' class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
|
||||||
<div class="spinner-ring">
|
<div class="spinner-ring">
|
||||||
<div class="spinner-track"></div>
|
<div class="spinner-track"></div>
|
||||||
<div class="spinner-head"></div>
|
<div class="spinner-head"></div>
|
||||||
@@ -25,24 +25,24 @@
|
|||||||
}
|
}
|
||||||
else if (_hasError)
|
else if (_hasError)
|
||||||
{
|
{
|
||||||
<div class="empty-state">
|
<div @key='"error"' class="empty-state">
|
||||||
<NexusIcon Name="alert-circle" Size="32" />
|
<NexusIcon Name="alert-circle" Size="32" />
|
||||||
<p>Nie udało się załadować rekomendacji.</p>
|
<p>Nie udało się załadować rekomendacji.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else if (_recommendations is null || _recommendations.Count == 0)
|
else if (_recommendations is null || _recommendations.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="empty-state">
|
<div @key='"empty"' class="empty-state">
|
||||||
<NexusIcon Name="book-open" Size="32" />
|
<NexusIcon Name="book-open" Size="32" />
|
||||||
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
|
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="recommendations-list" role="list">
|
<ul @key='"list"' class="recommendations-list" role="list">
|
||||||
@foreach (var rec in _recommendations)
|
@foreach (var rec in _recommendations)
|
||||||
{
|
{
|
||||||
<li class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
|
<li @key="rec.TargetBookId" class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
|
||||||
role="listitem">
|
role="listitem">
|
||||||
<div class="rec-content">
|
<div class="rec-content">
|
||||||
<div class="rec-meta">
|
<div class="rec-meta">
|
||||||
|
|||||||
+78
@@ -5,6 +5,7 @@
|
|||||||
.recommendations-panel {
|
.recommendations-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
background: var(--nexus-surface, #1a1a1e);
|
background: var(--nexus-surface, #1a1a1e);
|
||||||
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
|
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -251,3 +252,80 @@
|
|||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.theme-light .recommendations-panel {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .recommendations-panel:hover {
|
||||||
|
box-shadow: 0 8px 24px rgba(139, 130, 115, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .header-left h4 {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .spinner-track {
|
||||||
|
border: 3px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .loading-label,
|
||||||
|
.theme-light .empty-state {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .recommendation-item {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .recommendation-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .recommendation-item.premium {
|
||||||
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .recommendation-item.premium:hover {
|
||||||
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
|
background: rgba(245, 158, 11, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .recommendation-item.owned {
|
||||||
|
border-color: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .recommendation-item.owned:hover {
|
||||||
|
border-color: rgba(16, 185, 129, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .rec-book-title {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .rec-chapter-title {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .rec-action-btn {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .rec-action-btn:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border-color: rgba(16, 185, 129, 0.3);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .premium .rec-action-btn:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border-color: rgba(245, 158, 11, 0.3);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
<section class="current-reading-card glass-panel">
|
<section class="current-reading-card glass-panel">
|
||||||
@if (Book != null)
|
@if (Book != null)
|
||||||
{
|
{
|
||||||
<div class="card-layout">
|
<div @key='"current-reading-book"' class="card-layout">
|
||||||
<div class="book-cover">
|
<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>
|
||||||
|
|
||||||
<div class="book-details">
|
<div class="book-details">
|
||||||
<div class="header-info">
|
<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>
|
<span class="author-name">by @Book.Author.Name</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-nexus outline" @onclick="HandleContinueReading">
|
<button class="btn-nexus outline" @onclick="HandleContinueReading" aria-label="Kontynuuj czytanie">
|
||||||
Continue Reading
|
Kontynuuj czytanie
|
||||||
<NexusIcon Name="arrow-right" Size="16" />
|
<NexusIcon Name="arrow-right" Size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="empty-state">
|
<div @key='"current-reading-empty"' class="empty-state">
|
||||||
<div class="empty-icon">
|
<div class="empty-icon">
|
||||||
<NexusIcon Name="book-open" Size="48" />
|
<NexusIcon Name="book-open" Size="48" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -194,19 +194,155 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 767px) {
|
||||||
.card-layout {
|
.current-reading-card {
|
||||||
flex-direction: column;
|
background: #1e1e22; /* Lighter anthracite slate for depth */
|
||||||
align-items: center;
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); /* Ambient card shadow */
|
||||||
text-align: center;
|
padding: 14px;
|
||||||
gap: 1.5rem;
|
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;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.header-info, .chapter-progress {
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
@inject IReaderStateService StateService
|
@inject IReaderStateService StateService
|
||||||
@inject IThemeService ThemeService
|
@inject IThemeService ThemeService
|
||||||
|
|
||||||
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||||
<!-- Tab 1: Progress (Postęp) -->
|
<!-- Tab 1: Progress (Postęp) -->
|
||||||
<button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints">
|
<button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints">
|
||||||
<div class="progress-ring-wrapper">
|
<div class="progress-ring-wrapper">
|
||||||
@@ -112,9 +112,12 @@
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||||
|
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleThemeChanged() => InvokeAsync(StateHasChanged);
|
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private double GetDashOffset()
|
private double GetDashOffset()
|
||||||
{
|
{
|
||||||
@@ -160,5 +163,6 @@
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
|
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,16 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
overflow: visible; /* Critical to show elevated FAB */
|
overflow: visible; /* Critical to show elevated FAB */
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nexus-unified-mobile-toolbar.immersive-zen-mode {
|
||||||
|
transform: translateY(calc(100% + 24px)) !important;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Light Mode: Premium Paper Look */
|
/* Light Mode: Premium Paper Look */
|
||||||
.nexus-unified-mobile-toolbar.theme-light {
|
.nexus-unified-mobile-toolbar.theme-light {
|
||||||
background: rgba(244, 241, 234, 0.9);
|
background: rgba(244, 241, 234, 0.9);
|
||||||
|
|||||||
@@ -20,10 +20,10 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ILogger<ReaderCanvas> Logger
|
@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)
|
@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">
|
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
|
||||||
<NexusIcon Name="chevron-left" Size="18" />
|
<NexusIcon Name="chevron-left" Size="18" />
|
||||||
<span>Pulpit</span>
|
<span>Pulpit</span>
|
||||||
@@ -127,9 +127,10 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await Coordinator.ClearAsync();
|
await Coordinator.ClearAsync();
|
||||||
ThemeService.OnThemeChanged += HandleUpdate;
|
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||||
QuizService.OnQuizUpdated += HandleUpdate;
|
QuizService.OnQuizUpdated += HandleUpdate;
|
||||||
|
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
|
||||||
|
|
||||||
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
||||||
@@ -250,7 +251,7 @@
|
|||||||
if (_selfReference != null)
|
if (_selfReference != null)
|
||||||
{
|
{
|
||||||
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
|
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)
|
catch (Exception ex)
|
||||||
@@ -266,6 +267,17 @@
|
|||||||
await InteractionService.NotifyScrollPercentChanged(percent);
|
await InteractionService.NotifyScrollPercentChanged(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task HandleScrollDelta(bool hideBars)
|
||||||
|
{
|
||||||
|
if (StateService.IsBarsHidden != hideBars)
|
||||||
|
{
|
||||||
|
StateService.IsBarsHidden = hideBars;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public async Task HandleBlockReached(string blockId, string content)
|
public async Task HandleBlockReached(string blockId, string content)
|
||||||
{
|
{
|
||||||
@@ -451,6 +463,8 @@
|
|||||||
|
|
||||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private void HandleEscape()
|
private void HandleEscape()
|
||||||
{
|
{
|
||||||
if (ViewModel != null)
|
if (ViewModel != null)
|
||||||
@@ -466,9 +480,10 @@
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged -= HandleUpdate;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||||
|
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
|
||||||
|
|
||||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
min-height: calc(100vh - 180px);
|
min-height: calc(100vh - 180px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 0.75rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 3rem 4rem 15rem 4rem;
|
padding: 3rem 4rem 15rem 4rem;
|
||||||
/* Large padding-bottom for reachability, plus comfortable side margins */
|
/* Large padding-bottom for reachability, plus comfortable side margins */
|
||||||
@@ -69,10 +69,21 @@
|
|||||||
.block-wrapper {
|
.block-wrapper {
|
||||||
transition: all 0.5s ease;
|
transition: all 0.5s ease;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 2px 8px;
|
||||||
border: 1px solid transparent;
|
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 */
|
/* Typographic refinement for TextSegmentBlock */
|
||||||
::deep .nexus-ebook {
|
::deep .nexus-ebook {
|
||||||
font-family: 'Merriweather', serif !important;
|
font-family: 'Merriweather', serif !important;
|
||||||
@@ -90,12 +101,24 @@
|
|||||||
/* Warm charcoal for legibility */
|
/* 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 */
|
/* Callout Box styling for legacy blockquote segments */
|
||||||
::deep .nexus-ebook blockquote {
|
::deep .nexus-ebook blockquote {
|
||||||
background-color: rgba(255, 255, 255, 0.02);
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
border-left: 4px solid var(--nexus-neon);
|
border-left: 4px solid var(--nexus-neon);
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
margin: 1.5rem 0 1.5rem 0;
|
margin: 1rem 0 1rem 0;
|
||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -116,7 +139,7 @@
|
|||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 2rem 0;
|
margin: 1.25rem 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
border-left: 4px solid var(--nexus-neon);
|
border-left: 4px solid var(--nexus-neon);
|
||||||
@@ -344,9 +367,16 @@
|
|||||||
/* Ensure content is clear of bottom toolbar */
|
/* 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 {
|
.reader-flow-container {
|
||||||
|
padding-left: 18px !important;
|
||||||
|
padding-right: 18px !important;
|
||||||
padding-bottom: 4rem;
|
padding-bottom: 4rem;
|
||||||
/* Safe breathing room */
|
gap: 0.75rem !important; /* Tighter spacing on mobile */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +390,8 @@
|
|||||||
::deep .nexus-ebook h1 {
|
::deep .nexus-ebook h1 {
|
||||||
font-size: 1.35rem !important;
|
font-size: 1.35rem !important;
|
||||||
line-height: 1.4 !important;
|
line-height: 1.4 !important;
|
||||||
margin-top: 1.5rem !important;
|
margin-top: 0.5rem !important; /* Tighter margins on mobile */
|
||||||
margin-bottom: 1rem !important;
|
margin-bottom: 0.25rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,8 +411,14 @@
|
|||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-sizing: border-box;
|
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 {
|
.theme-light .nexus-mobile-reader-header {
|
||||||
background: rgba(249, 249, 249, 0.8);
|
background: rgba(249, 249, 249, 0.8);
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||||
@@ -568,3 +604,43 @@
|
|||||||
opacity: 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,4 +1,5 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
@using NexusReader.UI.Shared.Components.Molecules
|
@using NexusReader.UI.Shared.Components.Molecules
|
||||||
@using NexusReader.UI.Shared.Components.Atoms
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
@using NexusReader.Application.Abstractions.Services
|
@using NexusReader.Application.Abstractions.Services
|
||||||
@@ -6,13 +7,13 @@
|
|||||||
|
|
||||||
@if (!_isFullyLoaded)
|
@if (!_isFullyLoaded)
|
||||||
{
|
{
|
||||||
<div class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000; color: #ffffff;">
|
<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-spinner"></div>
|
||||||
<div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div>
|
<div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div 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>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<!-- Mobile Sticky Top-bar -->
|
<!-- Mobile Sticky Top-bar -->
|
||||||
@@ -94,6 +95,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Koncentry</span>
|
<span class="nav-text">Koncentry</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="/creator" @onclick="CloseMobileMenu" title="Kreator" aria-label="Kreator">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<NexusIcon Name="edit" Size="20" />
|
||||||
|
</div>
|
||||||
|
<span class="nav-text">Kreator</span>
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -110,6 +117,32 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Reader Mobile Dock v3 -->
|
||||||
|
<nav class="reader-mobile-dock">
|
||||||
|
<NavLink class="dock-item" href="/dashboard" Match="NavLinkMatch.All" title="Pulpit">
|
||||||
|
<NexusIcon Name="home" Size="20" />
|
||||||
|
<span class="dock-text">Pulpit</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="dock-item" href="/catalog" title="Katalog">
|
||||||
|
<NexusIcon Name="layout" Size="20" />
|
||||||
|
<span class="dock-text">Katalog</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="dock-item central-action" href="/intelligence" title="Globalne AI">
|
||||||
|
<div class="central-action-inner">
|
||||||
|
<NexusIcon Name="robot" Size="20" />
|
||||||
|
</div>
|
||||||
|
<span class="dock-text">AI</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="dock-item" href="/my-books" title="Moje">
|
||||||
|
<NexusIcon Name="book-open" Size="20" />
|
||||||
|
<span class="dock-text">Moje</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="dock-item" href="/profile" title="Konto">
|
||||||
|
<NexusIcon Name="user" Size="20" />
|
||||||
|
<span class="dock-text">Konto</span>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@@ -124,6 +157,7 @@
|
|||||||
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
|
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
|
||||||
[Inject] private IIdentityService IdentityService { get; set; } = default!;
|
[Inject] private IIdentityService IdentityService { get; set; } = default!;
|
||||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||||
|
[Inject] private IThemeService ThemeService { get; set; } = default!;
|
||||||
|
|
||||||
private bool _isSyncing = false;
|
private bool _isSyncing = false;
|
||||||
private bool _isMobileMenuOpen = false;
|
private bool _isMobileMenuOpen = false;
|
||||||
@@ -131,6 +165,8 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||||
|
|
||||||
if (_isSyncing) return;
|
if (_isSyncing) return;
|
||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
@@ -142,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)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
|
await ThemeService.InitializeAsync();
|
||||||
_isFullyLoaded = true;
|
_isFullyLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
@@ -167,4 +209,10 @@
|
|||||||
await IdentityService.LogoutAsync();
|
await IdentityService.LogoutAsync();
|
||||||
NavigationManager.NavigateTo("/account/logout-form", true);
|
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #121214;
|
background: var(--bg-base);
|
||||||
color: #e4e4e7;
|
color: var(--text-main);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .hub-sidebar {
|
::deep .hub-sidebar {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #0d0d0d;
|
background: var(--bg-surface);
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
color: #8b8273;
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease, background-color 0.2s ease;
|
transition: color 0.2s ease, background-color 0.2s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
::deep .nav-item:hover {
|
::deep .nav-item:hover {
|
||||||
color: #10b981;
|
color: #10b981;
|
||||||
background: rgba(255, 255, 255, 0.01);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nav-item:focus-visible {
|
::deep .nav-item:focus-visible {
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
|
|
||||||
::deep .sidebar-footer {
|
::deep .sidebar-footer {
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
border-top: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -119,15 +119,15 @@
|
|||||||
::deep .user-avatar {
|
::deep .user-avatar {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background: #1a1a1e;
|
background: var(--bg-base);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e4e4e7;
|
color: var(--text-main);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
::deep .logout-btn {
|
::deep .logout-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #8b8273;
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #121214;
|
background: var(--bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-content {
|
.hub-content {
|
||||||
@@ -194,7 +194,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.reader-mobile-dock {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
.nexus-mobile-topbar {
|
.nexus-mobile-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -204,10 +208,10 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: rgba(18, 18, 18, 0.85);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-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;
|
padding: 0 1.25rem;
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
}
|
}
|
||||||
@@ -262,7 +266,7 @@
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: linear-gradient(135deg, var(--nexus-neon) 0%, #0099ff 100%);
|
background: linear-gradient(135deg, var(--nexus-neon) 0%, #0099ff 100%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -295,7 +299,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #141414;
|
background: var(--bg-surface);
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
@@ -324,7 +328,7 @@
|
|||||||
|
|
||||||
::deep .sidebar-header {
|
::deep .sidebar-header {
|
||||||
padding: 1.5rem 1.25rem;
|
padding: 1.5rem 1.25rem;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .sidebar-nav {
|
::deep .sidebar-nav {
|
||||||
@@ -342,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -343,7 +343,7 @@
|
|||||||
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
|
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
|
||||||
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
|
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
|
||||||
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
||||||
ThemeService.OnThemeChanged += HandleThemeChangedAsync;
|
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||||
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
|
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
|
||||||
|
|
||||||
var context = PlatformService.GetDeviceContext();
|
var context = PlatformService.GetDeviceContext();
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleThemeChangedAsync() => await InvokeAsync(StateHasChanged);
|
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private void SetActiveTab(SidebarTab tab)
|
private void SetActiveTab(SidebarTab tab)
|
||||||
{
|
{
|
||||||
@@ -520,7 +520,7 @@
|
|||||||
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
||||||
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
|
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
|
||||||
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
||||||
ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
|
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<span>lub</span>
|
<span>lub</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
|
<EditForm FormName="login-form" Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form 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="email" value="@_loginModel.Email" />
|
||||||
<input type="hidden" name="password" value="@_loginModel.Password" />
|
<input type="hidden" name="password" value="@_loginModel.Password" />
|
||||||
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
|
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
|
||||||
@@ -117,7 +117,10 @@
|
|||||||
[SupplyParameterFromQuery(Name = "returnUrl")]
|
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||||
public string? ReturnUrl { get; set; }
|
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 string? _errorMessage;
|
||||||
private bool _isSubmitting;
|
private bool _isSubmitting;
|
||||||
private bool _showPassword;
|
private bool _showPassword;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
@using NexusReader.UI.Shared.Components.Atoms
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@using NexusReader.Domain.Enums
|
||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject IThemeService ThemeService
|
||||||
|
|
||||||
<div class="profile-page-container">
|
<div class="profile-page-container">
|
||||||
<div class="background-radial"></div>
|
<div class="background-radial"></div>
|
||||||
@@ -97,6 +99,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme Preferences Card -->
|
||||||
|
<div class="metric-card glass-panel full-width theme-preference-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<NexusIcon Name="settings" Size="24" Color="#10b981" />
|
||||||
|
<h3>Preferencje Wizualne</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body theme-selector-layout">
|
||||||
|
<p class="theme-description">Wybierz profil wizualny systemu zoptymalizowany dla Twojego urządzenia i warunków czytania.</p>
|
||||||
|
<div class="theme-options">
|
||||||
|
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.System ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.System)">
|
||||||
|
<NexusIcon Name="cpu" Size="16" />
|
||||||
|
<span>Systemowy</span>
|
||||||
|
</button>
|
||||||
|
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.Dark ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.Dark)">
|
||||||
|
<NexusIcon Name="moon" Size="16" />
|
||||||
|
<span>Modern Deep Dark</span>
|
||||||
|
</button>
|
||||||
|
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.LightSepia ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.LightSepia)">
|
||||||
|
<NexusIcon Name="sun" Size="16" />
|
||||||
|
<span>Warm Sepia</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -110,6 +137,7 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
await ThemeService.InitializeAsync();
|
||||||
var result = await IdentityService.GetProfileAsync();
|
var result = await IdentityService.GetProfileAsync();
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -118,6 +146,12 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ChangeTheme(ThemeMode mode)
|
||||||
|
{
|
||||||
|
await ThemeService.SetThemeAsync(mode);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private int CalculateProgress()
|
private int CalculateProgress()
|
||||||
{
|
{
|
||||||
if (_profile == null || _profile.AITokenLimit == 0) return 0;
|
if (_profile == null || _profile.AITokenLimit == 0) return 0;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #121214;
|
background-color: var(--bg-base);
|
||||||
color: #e4e4e7;
|
color: var(--text-main);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
.mesh-overlay {
|
.mesh-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; width: 100%; height: 100%;
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
background-image: radial-gradient(circle at 1px 1px, 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;
|
background-size: 32px 32px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
.avatar-inner {
|
.avatar-inner {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 2px solid #10b981;
|
border: 2px solid #10b981;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-rank {
|
.system-rank {
|
||||||
@@ -120,17 +120,17 @@
|
|||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-panel:hover {
|
.glass-panel:hover {
|
||||||
border-color: rgba(16, 185, 129, 0.2);
|
border-color: var(--accent);
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
background: #1e1e24;
|
background: var(--bg-surface);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: #a0aec0;
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,14 +177,14 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: #fff; line-height: 1; }
|
.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: #4a5568; }
|
.usage-values .separator { font-size: 1.2rem; color: var(--border); }
|
||||||
.usage-values .total { font-size: 1.2rem; color: #718096; font-weight: 600; }
|
.usage-values .total { font-size: 1.2rem; color: var(--text-muted); font-weight: 600; }
|
||||||
|
|
||||||
.usage-progress {
|
.usage-progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #718096;
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
|
|
||||||
.score-label {
|
.score-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #718096;
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@@ -229,11 +229,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
background: rgba(16, 185, 129, 0.05);
|
background: var(--bg-base);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #cbd5e0;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.truncate {
|
.truncate {
|
||||||
@@ -273,9 +273,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.plan-badge.free {
|
.plan-badge.free {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-base);
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-tag {
|
.tenant-tag {
|
||||||
@@ -348,3 +348,111 @@
|
|||||||
.btn-nexus { width: 100%; justify-content: center; }
|
.btn-nexus { width: 100%; justify-content: center; }
|
||||||
.username { font-size: 2.2rem; }
|
.username { font-size: 2.2rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme Preference Card Styles */
|
||||||
|
.theme-preference-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option-btn.active {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.theme-options {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Light Theme Overrides — Warm Paper / Soft Sepia
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Background radial — warmer, slightly stronger glow */
|
||||||
|
.theme-light .background-radial {
|
||||||
|
background: radial-gradient(circle, rgba(16, 185, 129, 0.04) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar — keep green accent, reduce glow intensity */
|
||||||
|
.theme-light .avatar-inner {
|
||||||
|
box-shadow: 0 0 20px rgba(16, 185, 129, 0.12), inset 0 0 15px rgba(16, 185, 129, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar glow ring — softer border */
|
||||||
|
.theme-light .avatar-glow {
|
||||||
|
border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass panel hover — warm sepia shadow instead of pure black */
|
||||||
|
.theme-light .glass-panel:hover {
|
||||||
|
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar — reduce neon glow */
|
||||||
|
.theme-light .progress-bar {
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative text — dark ink on light bg instead of light on dark */
|
||||||
|
.theme-light .decoration {
|
||||||
|
color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tenant tag — warm stone gray */
|
||||||
|
.theme-light .tenant-tag {
|
||||||
|
color: #78716c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loader — disable neon drop-shadow, softer border */
|
||||||
|
.theme-light .nexus-loader {
|
||||||
|
border-color: rgba(16, 185, 129, 0.15);
|
||||||
|
border-top-color: #10b981;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme option active — reduce glow in light mode */
|
||||||
|
.theme-light .theme-option-btn.active {
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar track — light stone gray */
|
||||||
|
.theme-light .usage-progress {
|
||||||
|
background: #e4e1d9;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<p class="auth-subtitle">Utwórz nowe konto</p>
|
<p class="auth-subtitle">Utwórz nowe konto</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
|
<EditForm FormName="register-form" Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
@@ -71,14 +71,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="email" value="@_registerModel.Email" />
|
||||||
<input type="hidden" name="password" value="@_registerModel.Password" />
|
<input type="hidden" name="password" value="@_registerModel.Password" />
|
||||||
<input type="hidden" name="rememberMe" value="false" />
|
<input type="hidden" name="rememberMe" value="false" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
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 string? _errorMessage;
|
||||||
private bool _isSubmitting;
|
private bool _isSubmitting;
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-header .subtitle {
|
.catalog-header .subtitle {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,27 +38,27 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-card:hover {
|
.course-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
|
||||||
border-color: rgba(16, 185, 129, 0.2);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-cover-container {
|
.card-cover-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-cover {
|
.card-cover {
|
||||||
@@ -147,8 +147,9 @@
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -175,21 +176,21 @@
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 0.4rem 0;
|
margin: 0 0 0.4rem 0;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
|
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-author {
|
.course-author {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-desc {
|
.course-desc {
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
margin: 0 0 1.5rem 0;
|
margin: 0 0 1.5rem 0;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
@@ -204,8 +205,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
border-top: 1px solid var(--border);
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,14 +257,14 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 440px;
|
height: 440px;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-cover {
|
.skeleton-cover {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: loading 1.5s infinite;
|
animation: loading 1.5s infinite;
|
||||||
}
|
}
|
||||||
@@ -276,7 +277,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-line {
|
.skeleton-line {
|
||||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: loading 1.5s infinite;
|
animation: loading 1.5s infinite;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -314,9 +315,9 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
padding: 1.25rem 2.25rem;
|
padding: 1.25rem 2.25rem;
|
||||||
border-radius: 40px;
|
border-radius: 40px;
|
||||||
background: rgba(13, 13, 15, 0.85);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--border);
|
||||||
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +333,7 @@
|
|||||||
|
|
||||||
.loader-text {
|
.loader-text {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,3 +357,54 @@
|
|||||||
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
|
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
|
||||||
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIGHT THEME OVERRIDES — Warm Paper / Soft Sepia
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.theme-light .course-card:hover {
|
||||||
|
box-shadow: 0 12px 30px rgba(139, 130, 115, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .card-cover-container {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .cover-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .course-card:hover .start-action {
|
||||||
|
color: #292524;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .dotnet-gradient,
|
||||||
|
.theme-light .blazor-gradient,
|
||||||
|
.theme-light .graph-gradient {
|
||||||
|
background: #e4e1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .cover-code-text {
|
||||||
|
color: var(--text-main);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.catalog-page {
|
||||||
|
padding: 1.5rem 1rem calc(1.5rem + 72px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-grid, .loading-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1.25rem 2rem;
|
padding: 1.25rem 2rem;
|
||||||
background: rgba(20, 20, 20, 0.35);
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
backdrop-filter: blur(12px);
|
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 {
|
.header-back .btn-back {
|
||||||
@@ -30,22 +30,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-back .btn-back:hover {
|
.header-back .btn-back:hover {
|
||||||
border-color: var(--nexus-neon);
|
border-color: var(--accent);
|
||||||
color: var(--nexus-neon);
|
color: var(--accent);
|
||||||
background: var(--nexus-primary-glow);
|
background: var(--accent-glow);
|
||||||
box-shadow: 0 0 10px var(--nexus-primary-glow);
|
box-shadow: 0 0 10px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title h1 {
|
.header-title h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title .subtitle {
|
.header-title .subtitle {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions .btn-action {
|
.header-actions .btn-action {
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-actions .btn-action:hover {
|
.header-actions .btn-action:hover {
|
||||||
box-shadow: 0 0 20px var(--nexus-primary-glow);
|
box-shadow: 0 0 20px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid Layout */
|
/* Grid Layout */
|
||||||
@@ -73,28 +73,26 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xl, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-header {
|
.pane-header {
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-header h3 {
|
.pane-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: var(--text-main);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-content {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading, Error and Empty States */
|
/* Loading, Error and Empty States */
|
||||||
.loading-state, .error-state, .empty-dashboard-state {
|
.loading-state, .error-state, .empty-dashboard-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -118,15 +116,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.neon-pulse {
|
.neon-pulse {
|
||||||
color: var(--nexus-neon);
|
color: var(--accent);
|
||||||
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
filter: drop-shadow(0 0 10px var(--accent-glow));
|
||||||
animation: robot-pulse 2s infinite ease-in-out;
|
animation: robot-pulse 2s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes robot-pulse {
|
@keyframes robot-pulse {
|
||||||
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--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(--nexus-neon)); }
|
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(--nexus-neon)); }
|
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--accent-glow)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-line {
|
.scan-line {
|
||||||
@@ -135,8 +133,8 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--nexus-neon);
|
background: var(--accent);
|
||||||
box-shadow: 0 0 15px var(--nexus-neon);
|
box-shadow: 0 0 15px var(--accent);
|
||||||
animation: scan 2s infinite linear;
|
animation: scan 2s infinite linear;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
@@ -149,7 +147,7 @@
|
|||||||
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: var(--text-muted);
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -164,17 +162,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dim-icon {
|
.dim-icon {
|
||||||
color: rgba(255, 255, 255, 0.15);
|
color: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-dashboard-state h2, .error-state h3 {
|
.empty-dashboard-state h2, .error-state h3 {
|
||||||
color: #fff;
|
color: var(--text-main);
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-dashboard-state p, .error-state p {
|
.empty-dashboard-state p, .error-state p {
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: var(--text-muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 2rem 0;
|
margin: 0 0 2rem 0;
|
||||||
@@ -189,25 +188,25 @@
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-glowing-brain {
|
.empty-glowing-brain {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 255, 153, 0.04);
|
background: var(--accent-glow);
|
||||||
border: 1px solid rgba(0, 255, 153, 0.15);
|
border: 1px solid var(--accent);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
box-shadow: 0 0 20px var(--nexus-primary-glow);
|
box-shadow: 0 0 20px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-empty h4 {
|
.workspace-empty h4 {
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
color: #fff;
|
color: var(--text-main);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -227,7 +226,7 @@
|
|||||||
|
|
||||||
.workspace-header {
|
.workspace-header {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-meta {
|
.node-meta {
|
||||||
@@ -242,7 +241,7 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
color: var(--nexus-neon);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -256,22 +255,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-unlocked {
|
.badge-unlocked {
|
||||||
background: rgba(0, 255, 153, 0.08);
|
background: var(--accent-glow);
|
||||||
color: var(--nexus-neon);
|
color: var(--accent);
|
||||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
border: 1px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-locked {
|
.badge-locked {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-base);
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-muted);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-title {
|
.workspace-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-body {
|
.workspace-body {
|
||||||
@@ -291,22 +290,22 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.workspace-body::-webkit-scrollbar-thumb {
|
.workspace-body::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.workspace-body::-webkit-scrollbar-thumb:hover {
|
.workspace-body::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--nexus-neon);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-warning {
|
.locked-warning {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
background: rgba(255, 171, 0, 0.04);
|
background: rgba(217, 119, 6, 0.05);
|
||||||
border: 1px solid rgba(255, 171, 0, 0.15);
|
border: 1px solid rgba(217, 119, 6, 0.15);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lock-warning-icon {
|
.lock-warning-icon {
|
||||||
@@ -326,14 +325,14 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: rgba(255, 255, 255, 0.55);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-section h4 {
|
.metadata-section h4 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #aaa;
|
color: var(--text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -345,12 +344,12 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-box {
|
.summary-box {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--bg-base);
|
||||||
border-left: 3px solid var(--nexus-neon);
|
border-left: 3px solid var(--accent);
|
||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
@@ -365,9 +364,9 @@
|
|||||||
|
|
||||||
.term-pill {
|
.term-pill {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--bg-base);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-muted);
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.3rem 0.75rem;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -375,14 +374,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.term-pill:hover {
|
.term-pill:hover {
|
||||||
border-color: rgba(0, 255, 153, 0.2);
|
border-color: var(--accent);
|
||||||
color: var(--nexus-neon);
|
color: var(--accent);
|
||||||
background: rgba(0, 255, 153, 0.03);
|
background: var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-footer {
|
.workspace-footer {
|
||||||
padding: 1.25rem 1.5rem;
|
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) {
|
@media (max-width: 1024px) {
|
||||||
@@ -403,3 +402,68 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Light Theme Overrides — Warm Paper / Soft Sepia
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Dashboard header — warm sepia shadow instead of pure black */
|
||||||
|
.theme-light .dashboard-header {
|
||||||
|
box-shadow: 0 4px 30px rgba(139, 130, 115, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neon pulse icon — disable glow filter entirely */
|
||||||
|
.theme-light .neon-pulse {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override the neon pulse keyframe states in light mode */
|
||||||
|
.theme-light .neon-pulse {
|
||||||
|
animation-name: robot-pulse-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes robot-pulse-light {
|
||||||
|
0% { transform: scale(1); filter: none; }
|
||||||
|
50% { transform: scale(1.08); filter: none; }
|
||||||
|
100% { transform: scale(1); filter: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scan line — reduce glow intensity */
|
||||||
|
.theme-light .scan-line {
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.3);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glowing brain empty state — subtle warm glow */
|
||||||
|
.theme-light .empty-glowing-brain {
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error icon — reduce drop-shadow intensity */
|
||||||
|
.theme-light .error-icon {
|
||||||
|
filter: drop-shadow(0 0 4px rgba(255, 74, 74, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back button hover — warm glow instead of neon */
|
||||||
|
.theme-light .header-back .btn-back:hover {
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action button hover — warm glow */
|
||||||
|
.theme-light .header-actions .btn-action:hover {
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.concepts-dashboard-container {
|
||||||
|
padding: 1rem 0.75rem calc(1.5rem + 72px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.dashboard-header {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.header-title h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
@page "/creator"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.Extensions.Logging
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using NexusReader.Application.DTOs.Creator
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject ILogger<CreatorDashboard> Logger
|
||||||
|
|
||||||
|
<PageTitle>Creator Dashboard | Nexus Reader</PageTitle>
|
||||||
|
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<div class="header-visual">
|
||||||
|
<h1 class="dashboard-title">Panel Autora</h1>
|
||||||
|
<p class="subtitle">Monitoruj zaangażowanie czytelników i publikuj wersje zamrożone z poziomu kontroli wersji.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="dashboard-content">
|
||||||
|
<!-- Metrics Section -->
|
||||||
|
<section class="metrics-grid">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
@for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
<div class="metric-card skeleton-card">
|
||||||
|
<div class="skeleton-line label"></div>
|
||||||
|
<div class="skeleton-line value"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_dashboardData != null)
|
||||||
|
{
|
||||||
|
<div class="metric-card glass-panel">
|
||||||
|
<span class="metric-label">Całkowite Odczyty</span>
|
||||||
|
<h2 class="metric-value">@_dashboardData.Metrics.TotalReads</h2>
|
||||||
|
<div class="metric-trend positive">
|
||||||
|
<span class="trend-icon">↑</span>
|
||||||
|
<span class="trend-text">System stabilny</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card glass-panel">
|
||||||
|
<span class="metric-label">Średni Czas Czytania</span>
|
||||||
|
<h2 class="metric-value">@_dashboardData.Metrics.AvgReadTimeMinutes min</h2>
|
||||||
|
<div class="metric-trend neutral">
|
||||||
|
<span class="trend-icon">→</span>
|
||||||
|
<span class="trend-text">Na rozdział</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card glass-panel">
|
||||||
|
<div class="metric-label-container">
|
||||||
|
<span class="metric-label">Aktywni Czytelnicy</span>
|
||||||
|
<div class="pulse-indicator">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
<span class="pulse-text">Live Now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="metric-value">@_dashboardData.Metrics.ActiveReaders</h2>
|
||||||
|
<div class="metric-trend positive">
|
||||||
|
<span class="trend-icon">↑</span>
|
||||||
|
<span class="trend-text">Ruch w czasie rzeczywistym</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card glass-panel">
|
||||||
|
<span class="metric-label">Przychód Gross</span>
|
||||||
|
<h2 class="metric-value">@_dashboardData.Metrics.GrossRevenue.ToString("C2")</h2>
|
||||||
|
<div class="metric-trend positive">
|
||||||
|
<span class="trend-icon">↑</span>
|
||||||
|
<span class="trend-text">Rozliczenia w toku</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Publication Cards Grid Section -->
|
||||||
|
<section class="publications-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Twoje Publikacje</h2>
|
||||||
|
<button type="button" class="btn-nexus primary glow-btn" @onclick="OpenCreateBookModal">
|
||||||
|
[ + Nowa Publikacja ]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<div class="books-grid">
|
||||||
|
@for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
<div class="book-card skeleton-card">
|
||||||
|
<div class="skeleton-card-header"></div>
|
||||||
|
<div class="skeleton-line title"></div>
|
||||||
|
<div class="skeleton-line metadata"></div>
|
||||||
|
<div class="skeleton-card-actions"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_dashboardData == null || !_dashboardData.Books.Any())
|
||||||
|
{
|
||||||
|
<div class="empty-state glass-panel">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Brak publikacji</h3>
|
||||||
|
<p>Nie utworzyłeś jeszcze żadnych książek do autorskiej edycji.</p>
|
||||||
|
<button type="button" class="btn-nexus primary glow-btn" style="margin-top: 1.5rem;" @onclick="OpenCreateBookModal">
|
||||||
|
[ + Nowa Publikacja ]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="books-grid">
|
||||||
|
@foreach (var book in _dashboardData.Books)
|
||||||
|
{
|
||||||
|
<div class="book-card glass-panel">
|
||||||
|
<div class="card-glow"></div>
|
||||||
|
<div class="book-card-header">
|
||||||
|
<h3 class="book-title" title="@book.Title">@book.Title</h3>
|
||||||
|
<div class="badges-row">
|
||||||
|
@if (book.LivePublishedRevision != null)
|
||||||
|
{
|
||||||
|
<span class="badge badge-published" title="Opublikowana wersja dostępna dla czytelników">
|
||||||
|
Live @book.LivePublishedRevision.VersionString
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (book.CurrentDraftRevision != null)
|
||||||
|
{
|
||||||
|
<span class="badge badge-draft pulsing" title="Szkic roboczy z nieopublikowanymi zmianami">
|
||||||
|
Szkic
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="book-telemetry">
|
||||||
|
<div class="telemetry-item">
|
||||||
|
<span class="telemetry-label">Słowa:</span>
|
||||||
|
<span class="telemetry-value">@book.WordCount.ToString("N0")</span>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry-item">
|
||||||
|
<span class="telemetry-label">Wyświetlenia:</span>
|
||||||
|
<span class="telemetry-value">@book.AggregatedReads.ToString("N0")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="book-card-actions">
|
||||||
|
<button type="button" class="btn-nexus secondary" @onclick="() => NavigateToEditor(book)">
|
||||||
|
Edytuj szkic
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-nexus primary glow-btn" @onclick="() => OpenPublishModal(book)">
|
||||||
|
Publikuj
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-nexus link-btn" @onclick="() => OpenRevisionsModalAsync(book)">
|
||||||
|
Rejestr zmian
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Defensively-Scoped Version Publish Modal -->
|
||||||
|
@if (_isPublishModalOpen && _activePublishBookId.HasValue)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop" @onclick="ClosePublishModal">
|
||||||
|
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Publikowanie Nowej Wersji</h3>
|
||||||
|
<button class="close-btn" @onclick="ClosePublishModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Zamrażasz obecny szkic książki <strong>@_activePublishBookTitle</strong> jako nową wersję publiczną.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="versionInput">Sygnatura Wersji (np. v1.0.0)</label>
|
||||||
|
<input id="versionInput" type="text" class="form-control" @bind="_customVersionString" @bind:event="oninput" placeholder="Wpisz tag wersji..." />
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
|
{
|
||||||
|
<div class="error-banner">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn-nexus secondary" @onclick="ClosePublishModal">Anuluj</button>
|
||||||
|
<button type="button" class="btn-nexus primary glow-btn" @onclick="SubmitPublishVersionAsync" disabled="@_isSubmitting">
|
||||||
|
@(_isSubmitting ? "Wysyłanie..." : "Zatwierdź wersję")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Manage Revisions Modal -->
|
||||||
|
@if (_isRevisionsModalOpen && _activeRevisionsBookId.HasValue)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop" @onclick="CloseRevisionsModal">
|
||||||
|
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Rejestr Rewizji: @_activeRevisionsBookTitle</h3>
|
||||||
|
<button class="close-btn" @onclick="CloseRevisionsModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_revisionsLoading)
|
||||||
|
{
|
||||||
|
<div class="spinner-container">
|
||||||
|
<div class="spinner-glow small"></div>
|
||||||
|
<span>Wczytywanie historii...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_revisionsList == null || !_revisionsList.Any())
|
||||||
|
{
|
||||||
|
<p class="empty-revisions">Brak zarejestrowanych rewizji.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="revisions-list">
|
||||||
|
@foreach (var revision in _revisionsList)
|
||||||
|
{
|
||||||
|
<div class="revision-item">
|
||||||
|
<div class="revision-header">
|
||||||
|
<span class="revision-tag @(revision.IsPublished ? "published" : "draft")">
|
||||||
|
@(revision.IsPublished ? revision.VersionString : "Szkic roboczy")
|
||||||
|
</span>
|
||||||
|
<span class="revision-date">
|
||||||
|
@(revision.PublishedAt.HasValue ? revision.PublishedAt.Value.ToString("g") : revision.CreatedAt.ToString("g"))
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="revision-meta">
|
||||||
|
<span>Utworzono: @revision.CreatedAt.ToString("yyyy-MM-dd HH:mm")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn-nexus secondary" @onclick="CloseRevisionsModal">Zamknij</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Create Book Modal -->
|
||||||
|
@if (_isCreateBookModalOpen)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop" @onclick="CloseCreateBookModal">
|
||||||
|
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Nowa Publikacja</h2>
|
||||||
|
<button class="close-btn" @onclick="CloseCreateBookModal">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<EditForm Model="_createBookModel" OnValidSubmit="SubmitCreateBookAsync">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="creator-layout">
|
||||||
|
<!-- Book Cover Preview -->
|
||||||
|
<div class="cover-preview creator-cover">
|
||||||
|
<div class="cover-mockup-design">
|
||||||
|
<div class="cover-accent-line"></div>
|
||||||
|
<div class="cover-main-content">
|
||||||
|
<span class="cover-title-text">
|
||||||
|
@(string.IsNullOrWhiteSpace(_createBookModel.Title) ? "Tytuł Książki" : _createBookModel.Title)
|
||||||
|
</span>
|
||||||
|
<span class="cover-author-text">
|
||||||
|
Szkic roboczy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-logo-container">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
|
<span class="cover-brand">Nexus</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form inputs -->
|
||||||
|
<div class="creator-form-inputs">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newBookTitle">Tytuł Książki</label>
|
||||||
|
<InputText id="newBookTitle" class="form-input" @bind-Value="_createBookModel.Title" placeholder="Wpisz tytuł książki..." />
|
||||||
|
<ValidationMessage For="@(() => _createBookModel.Title)" class="validation-message" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newBookDescription">Opis (opcjonalny)</label>
|
||||||
|
<InputTextArea id="newBookDescription" class="form-input" @bind-Value="_createBookModel.Description" rows="3" placeholder="Wpisz krótki opis książki..." />
|
||||||
|
<ValidationMessage For="@(() => _createBookModel.Description)" class="validation-message" />
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(_createBookError))
|
||||||
|
{
|
||||||
|
<div class="error-banner">@_createBookError</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-nexus secondary" @onclick="CloseCreateBookModal">Anuluj</button>
|
||||||
|
<button type="submit" class="btn-nexus primary glow-btn" disabled="@_isCreatingBook">
|
||||||
|
@(_isCreatingBook ? "Tworzenie..." : "Utwórz książkę")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private CreatorDashboardDataDto? _dashboardData;
|
||||||
|
|
||||||
|
// Create Book Model and state
|
||||||
|
private bool _isCreateBookModalOpen;
|
||||||
|
private CreateBookModel _createBookModel = new();
|
||||||
|
private bool _isCreatingBook;
|
||||||
|
private string? _createBookError;
|
||||||
|
|
||||||
|
// Defensively-scoped state variables for modal isolation
|
||||||
|
private bool _isPublishModalOpen;
|
||||||
|
private Guid? _activePublishBookId;
|
||||||
|
private string _activePublishBookTitle = string.Empty;
|
||||||
|
private string _customVersionString = string.Empty;
|
||||||
|
private bool _isSubmitting;
|
||||||
|
private string? _errorMessage;
|
||||||
|
|
||||||
|
// Revisions modal state variables
|
||||||
|
private bool _isRevisionsModalOpen;
|
||||||
|
private Guid? _activeRevisionsBookId;
|
||||||
|
private string _activeRevisionsBookTitle = string.Empty;
|
||||||
|
private bool _revisionsLoading;
|
||||||
|
private List<CreatorBookRevisionDto> _revisionsList = new();
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
await LoadDashboardDataAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDashboardDataAsync()
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_dashboardData = await Http.GetFromJsonAsync<CreatorDashboardDataDto>("api/creator/dashboard");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error loading creator dashboard data.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateToEditor(CreatorBookDto book)
|
||||||
|
{
|
||||||
|
if (book.FirstChapterId.HasValue)
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"/creator/edit/{book.Id}/{book.FirstChapterId.Value}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"/creator/edit/{book.Id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenPublishModal(CreatorBookDto book)
|
||||||
|
{
|
||||||
|
// Explicitly lock context boundaries to the selected book
|
||||||
|
_activePublishBookId = book.Id;
|
||||||
|
_activePublishBookTitle = book.Title;
|
||||||
|
_customVersionString = "v1.0.0";
|
||||||
|
_errorMessage = null;
|
||||||
|
_isPublishModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClosePublishModal()
|
||||||
|
{
|
||||||
|
_isPublishModalOpen = false;
|
||||||
|
_activePublishBookId = null;
|
||||||
|
_activePublishBookTitle = string.Empty;
|
||||||
|
_customVersionString = string.Empty;
|
||||||
|
_errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitPublishVersionAsync()
|
||||||
|
{
|
||||||
|
if (!_activePublishBookId.HasValue || string.IsNullOrWhiteSpace(_customVersionString))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSubmitting = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Explicitly lock the parameters during sending execution
|
||||||
|
var bookId = _activePublishBookId.Value;
|
||||||
|
var response = await Http.PostAsync($"api/creator/books/{bookId}/publish?version={Uri.EscapeDataString(_customVersionString)}", null);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
ClosePublishModal();
|
||||||
|
await LoadDashboardDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_errorMessage = await response.Content.ReadAsStringAsync();
|
||||||
|
if (string.IsNullOrWhiteSpace(_errorMessage))
|
||||||
|
{
|
||||||
|
_errorMessage = "Publish version endpoint returned an error.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Exception thrown during publication.");
|
||||||
|
_errorMessage = $"Mutation failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isSubmitting = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenRevisionsModalAsync(CreatorBookDto book)
|
||||||
|
{
|
||||||
|
_activeRevisionsBookId = book.Id;
|
||||||
|
_activeRevisionsBookTitle = book.Title;
|
||||||
|
_revisionsList = new();
|
||||||
|
_revisionsLoading = true;
|
||||||
|
_isRevisionsModalOpen = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_revisionsList = await Http.GetFromJsonAsync<List<CreatorBookRevisionDto>>($"api/creator/books/{book.Id}/revisions") ?? new();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to load revisions.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_revisionsLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseRevisionsModal()
|
||||||
|
{
|
||||||
|
_isRevisionsModalOpen = false;
|
||||||
|
_activeRevisionsBookId = null;
|
||||||
|
_activeRevisionsBookTitle = string.Empty;
|
||||||
|
_revisionsList.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateBookModal()
|
||||||
|
{
|
||||||
|
_createBookModel = new CreateBookModel();
|
||||||
|
_createBookError = null;
|
||||||
|
_isCreateBookModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseCreateBookModal()
|
||||||
|
{
|
||||||
|
_isCreateBookModalOpen = false;
|
||||||
|
_createBookError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitCreateBookAsync()
|
||||||
|
{
|
||||||
|
_isCreatingBook = true;
|
||||||
|
_createBookError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.PostAsJsonAsync("api/creator/books", _createBookModel);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<CreateBookResponseDto>();
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
// Reset modal state BEFORE routing to prevent it lingering in the DOM tree
|
||||||
|
_isCreateBookModalOpen = false;
|
||||||
|
_createBookError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
NavigationManager.NavigateTo($"/creator/edit/{result.BookId}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_createBookError = "Otrzymano nieprawidłową odpowiedź z serwera.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var errorMsg = await response.Content.ReadAsStringAsync();
|
||||||
|
_createBookError = !string.IsNullOrWhiteSpace(errorMsg) ? errorMsg : "Wystąpił błąd podczas tworzenia książki.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Błąd podczas tworzenia książki.");
|
||||||
|
_createBookError = $"Krytyczny błąd: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isCreatingBook = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateBookModel
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "Tytuł książki jest wymagany.")]
|
||||||
|
[StringLength(255, ErrorMessage = "Tytuł książki nie może przekraczać 255 znaków.")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
.dashboard-container {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: fade-in 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(15px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Dashboard Header --- */
|
||||||
|
.dashboard-header {
|
||||||
|
padding: 3rem 2rem 2rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-family: var(--nexus-font-serif, serif);
|
||||||
|
font-size: 2.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 600px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Main Content Layout --- */
|
||||||
|
.dashboard-content {
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Metrics Grid --- */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-trend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-trend.positive {
|
||||||
|
color: var(--nexus-neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-trend.neutral {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Panel styles */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.03);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 10px 30px rgba(16, 185, 129, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pulsing indicator --- */
|
||||||
|
.pulse-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: #ff4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: beacon-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-text {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff4444;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes beacon-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Publications Section --- */
|
||||||
|
.publications-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-family: var(--nexus-font-serif, serif);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Publication Grid & Cards --- */
|
||||||
|
.books-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.books-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.books-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--border), var(--accent), var(--border));
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card:hover .card-glow {
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--nexus-neon), var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-published {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--nexus-neon);
|
||||||
|
border-color: rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-draft {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
border-color: rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-draft.pulsing {
|
||||||
|
animation: draft-pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes draft-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.2); }
|
||||||
|
50% { box-shadow: 0 0 8px 2px rgba(245, 158, 11, 0.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-telemetry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-actions .btn-nexus {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-actions .link-btn {
|
||||||
|
flex: unset;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons --- */
|
||||||
|
.btn-nexus {
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.primary {
|
||||||
|
background: #10b981;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.secondary {
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.link-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.primary:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.secondary:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.link-btn:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.glow-btn {
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nexus.glow-btn:hover {
|
||||||
|
box-shadow: 0 0 20px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal styles --- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fade-in-backdrop 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal Content & Header --- */
|
||||||
|
.modal-content {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 620px; /* Wider split layout */
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 2rem !important;
|
||||||
|
animation: modal-slide 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-backdrop {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-slide {
|
||||||
|
from { opacity: 0; transform: translateY(-30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2, .modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #10b981;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control, .form-input, ::deep .form-control, ::deep .form-input {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus, .form-input:focus, ::deep .form-control:focus, ::deep .form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
box-shadow: 0 0 15px rgba(16, 185, 129, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Revisions list --- */
|
||||||
|
.spinner-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-tag.published {
|
||||||
|
color: var(--nexus-neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-tag.draft {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-revisions {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loading skeleton animations --- */
|
||||||
|
.skeleton-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||||
|
animation: skeleton-glow 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-glow {
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line.label {
|
||||||
|
width: 60%;
|
||||||
|
height: 12px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line.value {
|
||||||
|
width: 40%;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card-header {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line.title {
|
||||||
|
width: 70%;
|
||||||
|
height: 16px;
|
||||||
|
margin: 1.5rem 1.5rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line.metadata {
|
||||||
|
width: 40%;
|
||||||
|
height: 12px;
|
||||||
|
margin: 0 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card-actions {
|
||||||
|
height: 38px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile View Adjustments --- */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dashboard-header {
|
||||||
|
padding: 1.5rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-actions .btn-nexus {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-message {
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split Layout for Creator Modal */
|
||||||
|
.creator-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-cover {
|
||||||
|
width: 140px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4);
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-mockup-design {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-accent-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 10px;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(16, 185, 129, 0.2); /* green accent line */
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-main-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
z-index: 2;
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-title-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-author-text {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
z-index: 2;
|
||||||
|
color: #10b981;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-logo-container svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-brand {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-form-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
@page "/creator/edit/{BookId}"
|
||||||
|
@page "/creator/edit/{BookId}/{ChapterId}"
|
||||||
|
@layout MainHubLayout
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using NexusReader.UI.Shared.Components
|
||||||
|
|
||||||
|
@if (_loadingChapters)
|
||||||
|
{
|
||||||
|
<div class="hub-loading" style="height: calc(100vh - 4rem); display: flex; flex-direction: column; justify-content: center; align-items: center; background-color: var(--bg-base);">
|
||||||
|
<div class="nexus-loader"></div>
|
||||||
|
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Ładowanie struktury książki...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="creator-edit-fullscreen-wrapper">
|
||||||
|
|
||||||
|
<div class="chapters-sidebar">
|
||||||
|
<div class="sidebar-meta-header">
|
||||||
|
<h2>Rozdziały</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chapters-list-wrapper">
|
||||||
|
@foreach (var ch in _chapters)
|
||||||
|
{
|
||||||
|
var isActive = ch.Id == _activeChapterId;
|
||||||
|
<a class="chapter-item @(isActive ? "active" : "")" href="/creator/edit/@BookId/@ch.Id">
|
||||||
|
@if (isActive)
|
||||||
|
{
|
||||||
|
<div class="active-indicator"></div>
|
||||||
|
<i class="fa-solid fa-book-open chapter-icon"></i>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="fa-solid fa-file-lines chapter-icon"></i>
|
||||||
|
}
|
||||||
|
<span class="chapter-title-text">@ch.Title</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-workspace-area">
|
||||||
|
|
||||||
|
<div class="editor-header-row">
|
||||||
|
<div class="title-zone">
|
||||||
|
<h1 class="chapter-title">@_activeChapterTitle</h1>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry-zone">
|
||||||
|
<span class="chapter-id-badge">ID: @_activeChapterId</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-canvas-card">
|
||||||
|
@if (_loadingChapter)
|
||||||
|
{
|
||||||
|
<div class="hub-loading" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center;">
|
||||||
|
<div class="nexus-loader"></div>
|
||||||
|
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Wczytywanie treści rozdziału...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_isChapterLoaded)
|
||||||
|
{
|
||||||
|
<div class="milkdown-premium-container" spellcheck="false">
|
||||||
|
<MarkdownEditor @key="_activeChapterId"
|
||||||
|
ChapterId="_activeChapterId"
|
||||||
|
InitialMarkdown="@_initialMarkdown"
|
||||||
|
ShowFetchButton="false" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); font-family: var(--nexus-font-sans);">
|
||||||
|
<p>Wybierz lub utwórz rozdział, aby rozpocząć edycję.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] private HttpClient Http { get; set; } = default!;
|
||||||
|
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||||
|
|
||||||
|
[Parameter] public string BookId { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string ChapterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private List<ChapterListItem> _chapters = new();
|
||||||
|
private Guid _parsedBookId = Guid.Empty;
|
||||||
|
private Guid _activeChapterId = Guid.Empty;
|
||||||
|
private string _activeChapterTitle = string.Empty;
|
||||||
|
private string _initialMarkdown = string.Empty;
|
||||||
|
private bool _loadingChapters = true;
|
||||||
|
private bool _loadingChapter = false;
|
||||||
|
private bool _isChapterLoaded = false;
|
||||||
|
|
||||||
|
private class ChapterListItem
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ChapterDetail
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string MarkdownContent { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
await base.OnParametersSetAsync();
|
||||||
|
|
||||||
|
if (!Guid.TryParse(BookId, out var parsedBookId))
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("/creator");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_parsedBookId = parsedBookId;
|
||||||
|
|
||||||
|
// Fetch chapters list if empty or if book ID has changed
|
||||||
|
if (_chapters.Count == 0)
|
||||||
|
{
|
||||||
|
_loadingChapters = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var chapters = await Http.GetFromJsonAsync<List<ChapterListItem>>($"/api/creator/books/{_parsedBookId}/chapters");
|
||||||
|
_chapters = chapters ?? new();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[CreatorEdit] Error fetching chapters list: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadingChapters = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ChapterId is empty/null, select the first chapter from list and navigate
|
||||||
|
if (string.IsNullOrEmpty(ChapterId))
|
||||||
|
{
|
||||||
|
if (_chapters.Any())
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"/creator/edit/{BookId}/{_chapters.First().Id}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Guid.TryParse(ChapterId, out var parsedChapterId))
|
||||||
|
{
|
||||||
|
// If active chapter changed, fetch its details
|
||||||
|
if (parsedChapterId != _activeChapterId)
|
||||||
|
{
|
||||||
|
_activeChapterId = parsedChapterId;
|
||||||
|
var ch = _chapters.FirstOrDefault(c => c.Id == _activeChapterId);
|
||||||
|
_activeChapterTitle = ch?.Title ?? "Rozdział";
|
||||||
|
|
||||||
|
_loadingChapter = true;
|
||||||
|
_isChapterLoaded = false;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var detail = await Http.GetFromJsonAsync<ChapterDetail>($"/api/chapters/{_activeChapterId}");
|
||||||
|
if (detail != null)
|
||||||
|
{
|
||||||
|
_initialMarkdown = detail.MarkdownContent;
|
||||||
|
_isChapterLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[CreatorEdit] Error fetching chapter content: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadingChapter = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
NEXUSREADER CREATOR EDIT MODE - HIGH-FIDELITY SAAS PREMIUM DESIGN OVERRIDE
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* 1. ARCHITECTURAL BOUNDARY CONTROL */
|
||||||
|
.creator-edit-fullscreen-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: calc(100vh - 4rem) !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
background-color: #121214;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamic theme bridge mapping for Warm Paper mode */
|
||||||
|
.theme-light .creator-edit-fullscreen-wrapper {
|
||||||
|
background-color: #f4f1ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. UNIFIED SIDEBAR DESIGN (Eliminating layout color fragmentation) */
|
||||||
|
.chapters-sidebar {
|
||||||
|
width: 300px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: #16161a !important;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 2.5rem 1.5rem !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapters-sidebar {
|
||||||
|
background-color: #eae6db !important; /* Rich warm tone that remains fully cohesive with warm paper base */
|
||||||
|
border-right: 1px solid #dcd7cc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-meta-header h2 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
margin: 0 0 1.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .sidebar-meta-header h2 {
|
||||||
|
color: #78716c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapters-list-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium Navigation Links */
|
||||||
|
.chapter-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-item {
|
||||||
|
color: #78716c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item i.chapter-icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #71717a;
|
||||||
|
transition: color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active Indicator Node Alignment */
|
||||||
|
.chapter-item.active {
|
||||||
|
background-color: rgba(0, 255, 153, 0.05) !important;
|
||||||
|
color: #00ff99 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-item.active {
|
||||||
|
background-color: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item.active i.chapter-icon {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item:hover:not(.active) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-item:hover:not(.active) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
color: #2d2a26;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. WORKSPACE METRICS (Zen presentation spacing) */
|
||||||
|
.editor-workspace-area {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 3rem 4rem 2.5rem 4rem !important; /* Generous padding context for premium scale */
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace-area h1.chapter-title {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .editor-workspace-area h1.chapter-title {
|
||||||
|
color: #2d2a26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-id-badge {
|
||||||
|
font-family: 'Azeret Mono', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #71717a;
|
||||||
|
background: #1a1a1e;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-id-badge {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #78716c;
|
||||||
|
border: 1px solid #dcd7cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. ELEVATED EDITOR CANVAS CARD (Introducing layered shadow mechanics) */
|
||||||
|
.editor-canvas-card {
|
||||||
|
background-color: #1a1a1e !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 3rem !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Soft diffuse structural shadows mimicking actual surface elevation */
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .editor-canvas-card {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border: 1px solid #dcd7cc !important;
|
||||||
|
box-shadow: 0 20px 50px rgba(45, 42, 38, 0.04), 0 4px 12px rgba(45, 42, 38, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DEEP MOUNTING COMPONENT INTEROP */
|
||||||
|
.milkdown-premium-container ::deep .milkdown {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror {
|
||||||
|
color: #e4e1d9 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
font-size: 1.15rem !important;
|
||||||
|
line-height: 1.8 !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
padding-right: 24px !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .ProseMirror {
|
||||||
|
color: #2d2a26 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Precise matching text selection token */
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror ::selection {
|
||||||
|
background-color: rgba(0, 255, 153, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .ProseMirror ::selection {
|
||||||
|
background-color: rgba(16, 185, 129, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Core webkit custom scrollbar mapping */
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb {
|
||||||
|
background: #dcd7cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. SEAMLESS INTEGRATED ACTIONS FOOTER BAR (OVERWRITING FOR MARKDOWNEDITOR COMPONENT INTEGRATION) */
|
||||||
|
.milkdown-premium-container ::deep .markdown-editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .milkdown-editor-wrapper {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .milkdown {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 2rem !important;
|
||||||
|
padding: 1.5rem 0 0 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .editor-footer {
|
||||||
|
border-top: 1px solid #dcd7cc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Telemetry cloud synchronization line mapping */
|
||||||
|
.milkdown-premium-container ::deep .status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: 'Azeret Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #71717a;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .status-indicator {
|
||||||
|
color: #78716c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot.saved {
|
||||||
|
background-color: #00ff99 !important;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 153, 0.8) !important;
|
||||||
|
color: #00ff99 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .status-dot.saved {
|
||||||
|
background-color: #10b981 !important;
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot.saving {
|
||||||
|
background-color: #F59E0B !important;
|
||||||
|
box-shadow: 0 0 10px rgba(245, 158, 11, 0.8) !important;
|
||||||
|
color: #F59E0B !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot.offline {
|
||||||
|
background-color: #EF4444 !important;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.8) !important;
|
||||||
|
color: #EF4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium Tactile Operational Button Trigger */
|
||||||
|
.milkdown-premium-container ::deep .nexus-btn {
|
||||||
|
background-color: #00ff99 !important;
|
||||||
|
color: #121214 !important;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: -0.1px;
|
||||||
|
padding: 11px 24px !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 255, 153, 0.15);
|
||||||
|
height: auto !important;
|
||||||
|
min-height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .nexus-btn {
|
||||||
|
background-color: #10b981 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .nexus-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 255, 153, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .nexus-btn:hover {
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject ISyncService SyncService
|
@inject ISyncService SyncService
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@using Microsoft.JSInterop
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
@@ -54,51 +56,86 @@
|
|||||||
<section class="integration-card glass-panel">
|
<section class="integration-card glass-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h4>Integracja Wiedzy</h4>
|
<h4>Integracja Wiedzy</h4>
|
||||||
<NexusIcon Name="arrow-right" Size="16" />
|
<button class="view-toggle-btn" @onclick="ToggleGraphMode" type="button" aria-label="Toggle View">
|
||||||
|
<NexusIcon Name="@(_showSimplifiedList ? "map" : "list")" Size="14" />
|
||||||
|
<span>@(_showSimplifiedList ? "Pokaż Wykres" : "Pokaż Listę")</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="graph-placeholder">
|
|
||||||
<div class="graph-node central" title="Ośrodek Wiedzy Nexus Reader"></div>
|
|
||||||
|
|
||||||
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
|
@if (_showSimplifiedList)
|
||||||
{
|
{
|
||||||
@for (int i = 0; i < _profile.MappedConcepts.Count; i++)
|
<div class="concepts-linear-stack">
|
||||||
|
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
|
||||||
{
|
{
|
||||||
var concept = _profile.MappedConcepts[i];
|
<div class="concept-linear-list">
|
||||||
var angle = i * (360.0 / _profile.MappedConcepts.Count);
|
@foreach (var concept in _profile.MappedConcepts)
|
||||||
var dist = 65;
|
{
|
||||||
<div class="graph-node satellite"
|
<div class="concept-linear-item">
|
||||||
style="--angle: @(angle)deg; --dist: @(dist)px;"
|
<span class="concept-badge @concept.Type.ToLower()">@concept.Type</span>
|
||||||
title="[@concept.Type] @concept.Content"
|
<span class="concept-text" title="@concept.Content">@concept.Content</span>
|
||||||
@onmouseover="() => SetHoveredConcept(concept)"
|
</div>
|
||||||
@onmouseout="ClearHoveredConcept">
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="concepts-empty-list">
|
||||||
|
<NexusIcon Name="info" Size="20" />
|
||||||
|
<p>Brak pojęć. Rozpocznij czytanie, aby AI wyodrębniło kluczowe koncepty.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
|
|
||||||
<div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
|
|
||||||
<div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="active-node-label">
|
|
||||||
@(string.IsNullOrEmpty(_hoveredConceptLabel) ? "TU JESTEŚ" : _hoveredConceptLabel)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_hoveredConcept != null)
|
|
||||||
{
|
|
||||||
<div class="concept-detail-toast">
|
|
||||||
<span class="concept-type">@_hoveredConcept.Type</span>
|
|
||||||
<p class="concept-content">@_hoveredConcept.Content</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="concept-detail-toast placeholder">
|
<div class="graph-placeholder">
|
||||||
<span class="concept-type">Mapowanie AI</span>
|
<div class="graph-node central" title="Ośrodek Wiedzy Nexus Reader"></div>
|
||||||
<p class="concept-content">Najedź na węzeł, aby zbadać pojęcie wydobyte przez Nexus AI.</p>
|
|
||||||
|
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
|
||||||
|
{
|
||||||
|
<div @key='"satellite-concepts-container"' style="display: contents;">
|
||||||
|
@for (int i = 0; i < _profile.MappedConcepts.Count; i++)
|
||||||
|
{
|
||||||
|
var concept = _profile.MappedConcepts[i];
|
||||||
|
var angle = i * (360.0 / _profile.MappedConcepts.Count);
|
||||||
|
var dist = 65;
|
||||||
|
<div @key="concept.Id" class="graph-node satellite"
|
||||||
|
style="--angle: @(angle)deg; --dist: @(dist)px;"
|
||||||
|
title="[@concept.Type] @concept.Content"
|
||||||
|
@onmouseover="() => SetHoveredConcept(concept)"
|
||||||
|
@onmouseout="ClearHoveredConcept">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div @key='"satellite-placeholders-container"' style="display: contents;">
|
||||||
|
<div @key='"satellite-placeholder-0"' class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
|
||||||
|
<div @key='"satellite-placeholder-1"' class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
|
||||||
|
<div @key='"satellite-placeholder-2"' class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="active-node-label">
|
||||||
|
@(string.IsNullOrEmpty(_hoveredConceptLabel) ? "TU JESTEŚ" : _hoveredConceptLabel)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_hoveredConcept != null)
|
||||||
|
{
|
||||||
|
<div class="concept-detail-toast">
|
||||||
|
<span class="concept-type">@_hoveredConcept.Type</span>
|
||||||
|
<p class="concept-content">@_hoveredConcept.Content</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="concept-detail-toast placeholder">
|
||||||
|
<span class="concept-type">Mapowanie AI</span>
|
||||||
|
<p class="concept-content">Najedź na węzeł, aby zbadać pojęcie wydobyte przez Nexus AI.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -111,10 +148,10 @@
|
|||||||
<div class="quiz-preview">
|
<div class="quiz-preview">
|
||||||
@if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any())
|
@if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any())
|
||||||
{
|
{
|
||||||
<div class="quiz-history-list">
|
<div @key='"quiz-history-list"' class="quiz-history-list">
|
||||||
@foreach (var quiz in _profile.RecentQuizzes)
|
@foreach (var quiz in _profile.RecentQuizzes)
|
||||||
{
|
{
|
||||||
<div class="quiz-history-item">
|
<div @key="quiz.Id" class="quiz-history-item">
|
||||||
<div class="quiz-item-header">
|
<div class="quiz-item-header">
|
||||||
<span class="quiz-topic">@quiz.Topic</span>
|
<span class="quiz-topic">@quiz.Topic</span>
|
||||||
<span class="quiz-score badge @(quiz.Percentage >= 80 ? "badge-success" : quiz.Percentage >= 50 ? "badge-warning" : "badge-danger")">
|
<span class="quiz-score badge @(quiz.Percentage >= 80 ? "badge-success" : quiz.Percentage >= 50 ? "badge-warning" : "badge-danger")">
|
||||||
@@ -130,7 +167,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="empty-quiz-state">
|
<div @key='"empty-quiz-state"' class="empty-quiz-state">
|
||||||
<p class="question">Brak rozwiązanych quizów</p>
|
<p class="question">Brak rozwiązanych quizów</p>
|
||||||
<p class="sub-text">Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.</p>
|
<p class="sub-text">Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,6 +206,32 @@
|
|||||||
private UserProfileDto? _profile;
|
private UserProfileDto? _profile;
|
||||||
private MappedConceptDto? _hoveredConcept;
|
private MappedConceptDto? _hoveredConcept;
|
||||||
private string _hoveredConceptLabel = string.Empty;
|
private string _hoveredConceptLabel = string.Empty;
|
||||||
|
private bool _showSimplifiedList = false;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var isMobile = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
|
||||||
|
if (isMobile)
|
||||||
|
{
|
||||||
|
_showSimplifiedList = true;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fallback for tests or prerendering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleGraphMode()
|
||||||
|
{
|
||||||
|
_showSimplifiedList = !_showSimplifiedList;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,16 +17,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #0d0d0d;
|
background: var(--bg-surface);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-grid-bg {
|
.header-grid-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
linear-gradient(var(--border) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||||||
background-size: 60px 60px;
|
background-size: 60px 60px;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 3px solid #1a1a1a;
|
border: 3px solid var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: #222;
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-glow {
|
.avatar-glow {
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
font-family: var(--nexus-font-sans);
|
font-family: var(--nexus-font-sans);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
@@ -103,17 +103,17 @@
|
|||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
padding: 0.6rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
background: rgba(16, 185, 129, 0.05);
|
background: var(--bg-base);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
border: 1px solid var(--border);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.1);
|
box-shadow: 0 0 15px rgba(16, 185, 129, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill-label { color: #A0A0A0; }
|
.pill-label { color: var(--text-muted); }
|
||||||
.pill-value { color: #ffffff; font-weight: 600; }
|
.pill-value { color: var(--text-main); font-weight: 600; }
|
||||||
|
|
||||||
/* --- Dashboard Content --- */
|
/* --- Dashboard Content --- */
|
||||||
.dashboard-content {
|
.dashboard-content {
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
font-family: var(--nexus-font-serif);
|
font-family: var(--nexus-font-serif);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-grid {
|
.main-grid {
|
||||||
@@ -137,18 +137,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-panel:hover {
|
.glass-panel:hover {
|
||||||
background: #1e1e24;
|
background: var(--bg-surface);
|
||||||
border-color: rgba(16, 185, 129, 0.2);
|
border-color: var(--accent);
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reading Card */
|
/* Reading Card */
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
.reading-card h3 {
|
.reading-card h3 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #E0E0E0;
|
color: var(--text-main);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
.reading-thumb img {
|
.reading-thumb img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reading-info {
|
.reading-info {
|
||||||
@@ -196,12 +196,12 @@
|
|||||||
|
|
||||||
.chapter-label {
|
.chapter-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #A0A0A0;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -228,13 +228,13 @@
|
|||||||
|
|
||||||
.progress-detail {
|
.progress-detail {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reading-desc {
|
.reading-desc {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #E0E0E0;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Graph Placeholder */
|
/* Graph Placeholder */
|
||||||
@@ -325,7 +325,7 @@
|
|||||||
|
|
||||||
.question {
|
.question {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #E0E0E0;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiz-options {
|
.quiz-options {
|
||||||
@@ -336,13 +336,14 @@
|
|||||||
|
|
||||||
.quiz-option {
|
.quiz-option {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--bg-base);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiz-option.active {
|
.quiz-option.active {
|
||||||
@@ -372,9 +373,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-nexus.secondary {
|
.btn-nexus.secondary {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-base);
|
||||||
color: #fff;
|
color: var(--text-main);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-nexus:hover {
|
.btn-nexus:hover {
|
||||||
@@ -417,16 +418,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quiz-history-item {
|
.quiz-history-item {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiz-history-item:hover {
|
.quiz-history-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--bg-base);
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiz-item-header {
|
.quiz-item-header {
|
||||||
@@ -440,13 +441,13 @@
|
|||||||
.quiz-topic {
|
.quiz-topic {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiz-item-meta {
|
.quiz-item-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666666;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -481,7 +482,7 @@
|
|||||||
|
|
||||||
.empty-quiz-state .sub-text {
|
.empty-quiz-state .sub-text {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #666666;
|
color: var(--text-muted);
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,8 +490,8 @@
|
|||||||
.concept-detail-toast {
|
.concept-detail-toast {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--bg-base);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -514,7 +515,7 @@
|
|||||||
.concept-content {
|
.concept-content {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: #E0E0E0;
|
color: var(--text-main);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
@@ -523,28 +524,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Dashboard Overrides */
|
/* Mobile Dashboard Overrides */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 767px) {
|
||||||
.dashboard-content {
|
.dashboard-content {
|
||||||
padding: 1.25rem 0.75rem;
|
padding: 0.5rem 0.5rem calc(1.5rem + 64px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-header {
|
.profile-header {
|
||||||
padding: 1.5rem 1rem;
|
padding: 10px 12px;
|
||||||
border-radius: 16px;
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-visual {
|
.profile-visual {
|
||||||
flex-direction: row;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: auto 1fr;
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: left;
|
gap: 0.5rem;
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-wrapper {
|
.avatar-wrapper {
|
||||||
width: 70px;
|
width: 40px;
|
||||||
height: 70px;
|
height: 40px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,45 +554,91 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.username {
|
||||||
font-size: 1.5rem;
|
font-size: 1.05rem;
|
||||||
margin-bottom: 0.25rem;
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-role {
|
.user-role {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pills {
|
.status-pills {
|
||||||
|
grid-column: span 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.5rem;
|
gap: 0.25rem;
|
||||||
justify-content: flex-start;
|
margin-top: 0.15rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
padding: 0.35rem 0.75rem;
|
flex: 1;
|
||||||
font-size: 0.75rem;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-grid {
|
.main-grid {
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr !important;
|
||||||
gap: 1.25rem !important;
|
gap: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-grid {
|
.secondary-grid {
|
||||||
|
display: grid !important;
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr !important;
|
||||||
gap: 1.25rem !important;
|
gap: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force all widgets to take 100% width and fit inside parent container nicely */
|
/* Force all widgets to take 100% width and fit inside parent container nicely */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
padding: 1.25rem !important;
|
padding: 12px !important;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compress Knowledge Graph and Concept stack heights to maximize visible fold space */
|
||||||
|
.graph-placeholder {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concepts-linear-stack {
|
||||||
|
height: 120px !important;
|
||||||
|
max-height: 120px !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-linear-item {
|
||||||
|
padding: 6px 8px !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
min-height: auto !important; /* Prevent touch target min-height expansion */
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge {
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
font-size: 0.65rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Expand touch-targets to 48px min height for interactive elements */
|
/* Expand touch-targets to 48px min height for interactive elements */
|
||||||
.btn-nexus, .quiz-option, .satellite, .logout-btn, .nav-item, .quiz-item {
|
.btn-nexus, .quiz-option, .satellite, .logout-btn, .nav-item, .quiz-item {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
@@ -603,8 +651,8 @@
|
|||||||
/* --- Architecture Guide Block --- */
|
/* --- Architecture Guide Block --- */
|
||||||
.architecture-guide-panel {
|
.architecture-guide-panel {
|
||||||
margin-top: 2.5rem;
|
margin-top: 2.5rem;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
@@ -618,7 +666,7 @@
|
|||||||
.architecture-content h3 {
|
.architecture-content h3 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
@@ -626,16 +674,286 @@
|
|||||||
.architecture-content p {
|
.architecture-content p {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #e4e4e7;
|
color: var(--text-main);
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.architecture-content code {
|
.architecture-content code {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-base);
|
||||||
color: #10b981;
|
color: #10b981;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.theme-light .username::before,
|
||||||
|
.theme-light .username::after {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .avatar-glow {
|
||||||
|
background: var(--accent);
|
||||||
|
filter: blur(15px);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .progress-container {
|
||||||
|
background: #e4e1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .progress-bar {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .progress-bubble {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .graph-node {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .graph-node.central {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .graph-node.satellite {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .graph-node.satellite:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 10px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .active-node-label {
|
||||||
|
background: rgba(16, 185, 129, 0.06);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .quiz-option.active {
|
||||||
|
background: rgba(16, 185, 129, 0.06);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .btn-nexus.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .btn-nexus.primary:hover {
|
||||||
|
background: #059669;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .empty-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .badge-success {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-type {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Concepts Linear Stack for Mobile/Utility Switch --- */
|
||||||
|
.concepts-linear-stack {
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-linear-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-linear-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-linear-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(0, 255, 153, 0.2);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge.rule {
|
||||||
|
background: rgba(255, 70, 70, 0.1);
|
||||||
|
color: #ff8b8b;
|
||||||
|
border: 1px solid rgba(255, 70, 70, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge.definition {
|
||||||
|
background: rgba(255, 176, 58, 0.1);
|
||||||
|
color: #ffd18c;
|
||||||
|
border: 1px solid rgba(255, 176, 58, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge.table {
|
||||||
|
background: rgba(217, 70, 239, 0.1);
|
||||||
|
color: #f5d0fe;
|
||||||
|
border: 1px solid rgba(217, 70, 239, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge.section {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #93c5fd;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge.bridge {
|
||||||
|
background: rgba(6, 182, 212, 0.1);
|
||||||
|
color: #67e8f9;
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-badge.concept {
|
||||||
|
background: rgba(0, 210, 196, 0.05);
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid rgba(0, 210, 196, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concepts-empty-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concepts-empty-list p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn:hover {
|
||||||
|
background: rgba(0, 255, 153, 0.08);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Light Theme Overrides for Concepts Stack --- */
|
||||||
|
.theme-light .concept-linear-item {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-linear-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-badge.rule {
|
||||||
|
background: rgba(220, 38, 38, 0.05);
|
||||||
|
color: #991b1b;
|
||||||
|
border-color: rgba(220, 38, 38, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-badge.definition {
|
||||||
|
background: rgba(217, 119, 6, 0.05);
|
||||||
|
color: #92400e;
|
||||||
|
border-color: rgba(217, 119, 6, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-badge.table {
|
||||||
|
background: rgba(192, 132, 252, 0.05);
|
||||||
|
color: #6b21a8;
|
||||||
|
border-color: rgba(192, 132, 252, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-badge.section {
|
||||||
|
background: rgba(37, 99, 235, 0.05);
|
||||||
|
color: #1e3a8a;
|
||||||
|
border-color: rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-badge.bridge {
|
||||||
|
background: rgba(8, 145, 178, 0.05);
|
||||||
|
color: #155e75;
|
||||||
|
border-color: rgba(8, 145, 178, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .concept-badge.concept {
|
||||||
|
background: rgba(13, 148, 136, 0.03);
|
||||||
|
color: #115e59;
|
||||||
|
border-color: rgba(13, 148, 136, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .view-toggle-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .view-toggle-btn:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.intelligence-page {
|
.intelligence-page {
|
||||||
margin: -2.5rem;
|
margin: -2.5rem;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #121214;
|
background: var(--bg-base);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -45,11 +45,11 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.chat-thread-container::-webkit-scrollbar-thumb {
|
.chat-thread-container::-webkit-scrollbar-thumb {
|
||||||
background: rgba(16, 185, 129, 0.2);
|
background: var(--accent-glow);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.chat-thread-container::-webkit-scrollbar-thumb:hover {
|
.chat-thread-container::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(16, 185, 129, 0.4);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubbles-scroll {
|
.chat-bubbles-scroll {
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
.welcome-prompt {
|
.welcome-prompt {
|
||||||
font-family: var(--nexus-font-sans, inherit);
|
font-family: var(--nexus-font-sans, inherit);
|
||||||
color: #e4e4e7;
|
color: var(--text-main);
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
/* Input Controls */
|
/* Input Controls */
|
||||||
.chat-input-controls {
|
.chat-input-controls {
|
||||||
padding: 1.5rem 4rem 3rem 4rem;
|
padding: 1.5rem 4rem 3rem 4rem;
|
||||||
background: linear-gradient(to top, #121214 70%, rgba(18, 18, 20, 0));
|
background: linear-gradient(to top, var(--bg-base) 70%, transparent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,13 +117,13 @@
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #8b8273;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nexus-select {
|
.nexus-select {
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid var(--border);
|
||||||
color: #e4e4e7;
|
color: var(--text-main);
|
||||||
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -138,38 +138,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nexus-select:focus {
|
.nexus-select:focus {
|
||||||
border-color: #10b981;
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
|
box-shadow: 0 0 8px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field-group {
|
.input-field-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field-group:focus-within {
|
.input-field-group:focus-within {
|
||||||
border-color: rgba(16, 185, 129, 0.5);
|
border-color: var(--accent);
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
box-shadow: 0 10px 35px rgba(16, 185, 129, 0.1);
|
box-shadow: 0 10px 35px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nexus-input {
|
.nexus-input {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
font-size: 0.975rem;
|
font-size: 0.975rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nexus-input::placeholder {
|
.nexus-input::placeholder {
|
||||||
color: #8b8273;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn {
|
.search-btn {
|
||||||
@@ -180,29 +180,31 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #10b981;
|
background: var(--accent);
|
||||||
border: none;
|
border: none;
|
||||||
color: #121214;
|
color: var(--bg-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn:hover:not(:disabled) {
|
.search-btn:hover:not(:disabled) {
|
||||||
background: #0d9668;
|
background: var(--accent);
|
||||||
|
opacity: 0.9;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn:disabled {
|
.search-btn:disabled {
|
||||||
background: rgba(26, 26, 30, 0.8);
|
background: var(--bg-base);
|
||||||
color: rgba(255, 255, 255, 0.2);
|
color: var(--text-muted);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.02);
|
opacity: 0.4;
|
||||||
|
border: 1px solid var(--border);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typing / Loading Indicators */
|
/* Typing / Loading Indicators */
|
||||||
.message-bubble.pending-bubble {
|
.message-bubble.pending-bubble {
|
||||||
border-color: rgba(16, 185, 129, 0.25);
|
border-color: var(--accent-glow);
|
||||||
background: rgba(16, 185, 129, 0.03);
|
background: var(--accent-glow);
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +218,7 @@
|
|||||||
.typing-indicator span {
|
.typing-indicator span {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background: #10b981;
|
background: var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
animation: typing-bounce 1.4s infinite ease-in-out both;
|
||||||
@@ -227,16 +229,16 @@
|
|||||||
|
|
||||||
.loading-label {
|
.loading-label {
|
||||||
font-size: 0.825rem;
|
font-size: 0.825rem;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-spinner {
|
.btn-spinner {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border: 2px solid rgba(18, 18, 20, 0.1);
|
border: 2px solid var(--border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: #121214;
|
border-top-color: var(--accent);
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,3 +262,66 @@
|
|||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.theme-light .welcome-prompt {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .welcome-icon svg {
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .welcome-icon svg circle {
|
||||||
|
fill: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .welcome-icon svg path[stroke^="rgba(139"] {
|
||||||
|
stroke: rgba(120, 113, 108, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .welcome-icon svg path[stroke^="rgba(139"][stroke-dasharray] {
|
||||||
|
stroke: rgba(120, 113, 108, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .input-field-group {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .input-field-group:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 10px 35px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .nexus-input {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .nexus-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .nexus-select {
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .nexus-select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chat-thread-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chat-thread-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,13 +19,13 @@
|
|||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title-section .subtitle {
|
.header-title-section .subtitle {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,27 +67,27 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-card:hover {
|
.book-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
|
||||||
border-color: rgba(16, 185, 129, 0.2);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-cover-container {
|
.book-cover-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 360px;
|
height: 360px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-cover {
|
.book-cover {
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 0.4rem 0;
|
margin: 0 0 0.4rem 0;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
|
|
||||||
.book-author {
|
.book-author {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
margin: 0 0 1.25rem 0;
|
margin: 0 0 1.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -232,14 +232,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 5rem 2rem;
|
padding: 5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon-pulse {
|
.empty-icon-pulse {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
animation: pulse 3s infinite alternate;
|
animation: pulse 3s infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,11 +247,11 @@
|
|||||||
font-family: var(--nexus-font-serif);
|
font-family: var(--nexus-font-serif);
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-container p {
|
.empty-state-container p {
|
||||||
color: #a1a1aa;
|
color: var(--text-muted);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 0 0 2rem 0;
|
margin: 0 0 2rem 0;
|
||||||
}
|
}
|
||||||
@@ -278,14 +278,14 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 480px;
|
height: 480px;
|
||||||
background: #1a1a1e;
|
background: var(--bg-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-cover {
|
.skeleton-cover {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: loading 1.5s infinite;
|
animation: loading 1.5s infinite;
|
||||||
}
|
}
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-line {
|
.skeleton-line {
|
||||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: loading 1.5s infinite;
|
animation: loading 1.5s infinite;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -336,9 +336,9 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
padding: 1.25rem 2.25rem;
|
padding: 1.25rem 2.25rem;
|
||||||
border-radius: 40px;
|
border-radius: 40px;
|
||||||
background: rgba(13, 13, 15, 0.85);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--border);
|
||||||
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
|
|
||||||
.loader-text {
|
.loader-text {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ffffff;
|
color: var(--text-main);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,3 +383,65 @@
|
|||||||
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
|
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
|
||||||
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIGHT THEME OVERRIDES — Warm Paper / Soft Sepia
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.theme-light .book-card:hover {
|
||||||
|
box-shadow: 0 12px 30px rgba(139, 130, 115, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .book-cover-container {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .cover-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .book-card:hover .read-action {
|
||||||
|
color: #292524;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .progress-bar {
|
||||||
|
background: #e4e1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.my-books-page {
|
||||||
|
padding: 1.5rem 1rem calc(1.5rem + 72px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-books-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-section h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-section .subtitle {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-book-trigger {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.books-grid, .loading-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-cover-container {
|
||||||
|
height: 200px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,3 +72,40 @@
|
|||||||
from { opacity: 0; transform: translateY(15px); }
|
from { opacity: 0; transform: translateY(15px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.theme-light .settings-page > h1 {
|
||||||
|
background: none;
|
||||||
|
-webkit-text-fill-color: initial;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .settings-page > p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .settings-section h2 {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .settings-section p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .diag-btn {
|
||||||
|
background: rgba(16, 185, 129, 0.05);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .diag-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ public interface IReaderStateService
|
|||||||
List<string> CurrentCheckpoints { get; set; }
|
List<string> CurrentCheckpoints { get; set; }
|
||||||
string CurrentBlockId { get; set; }
|
string CurrentBlockId { get; set; }
|
||||||
MobileReaderTab ActiveTab { get; set; }
|
MobileReaderTab ActiveTab { get; set; }
|
||||||
|
bool IsBarsHidden { get; set; }
|
||||||
|
event Func<Task>? OnBarsHiddenChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
namespace NexusReader.UI.Shared.Services;
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
public interface IThemeService
|
public interface IThemeService
|
||||||
{
|
{
|
||||||
|
ThemeMode Mode { get; }
|
||||||
bool IsLightMode { get; }
|
bool IsLightMode { get; }
|
||||||
event Func<Task>? OnThemeChanged;
|
event Action<ThemeMode>? OnThemeChanged;
|
||||||
|
|
||||||
Task InitializeAsync();
|
Task InitializeAsync();
|
||||||
|
Task SetThemeAsync(ThemeMode mode);
|
||||||
Task ToggleTheme();
|
Task ToggleTheme();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public sealed class ReaderStateService : IReaderStateService
|
|||||||
private List<string> _checkpoints = new();
|
private List<string> _checkpoints = new();
|
||||||
private string _blockId = string.Empty;
|
private string _blockId = string.Empty;
|
||||||
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
|
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
|
||||||
|
private bool _barsHidden;
|
||||||
|
|
||||||
|
public event Func<Task>? OnBarsHiddenChanged;
|
||||||
|
|
||||||
public int CurrentScrollPercentage
|
public int CurrentScrollPercentage
|
||||||
{
|
{
|
||||||
@@ -38,4 +41,23 @@ public sealed class ReaderStateService : IReaderStateService
|
|||||||
get { lock (_lock) return _activeTab; }
|
get { lock (_lock) return _activeTab; }
|
||||||
set { lock (_lock) _activeTab = value; }
|
set { lock (_lock) _activeTab = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsBarsHidden
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _barsHidden; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
bool changed;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
changed = _barsHidden != value;
|
||||||
|
_barsHidden = value;
|
||||||
|
}
|
||||||
|
if (changed && OnBarsHiddenChanged != null)
|
||||||
|
{
|
||||||
|
_ = OnBarsHiddenChanged.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,155 @@
|
|||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
namespace NexusReader.UI.Shared.Services;
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
public sealed class ThemeService : IThemeService
|
public sealed class ThemeService : IThemeService
|
||||||
{
|
{
|
||||||
private readonly IJSRuntime _jsRuntime;
|
private readonly IJSRuntime _jsRuntime;
|
||||||
public bool IsLightMode { get; private set; } = false;
|
private readonly IUserPreferenceStore _userPreferenceStore;
|
||||||
public event Func<Task>? OnThemeChanged;
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||||
|
private bool _isInitialized;
|
||||||
|
private bool _systemPrefersLight;
|
||||||
|
|
||||||
public ThemeService(IJSRuntime jsRuntime)
|
public ThemeMode Mode { get; private set; } = ThemeMode.System;
|
||||||
|
|
||||||
|
public bool IsLightMode => Mode == ThemeMode.LightSepia || (Mode == ThemeMode.System && _systemPrefersLight);
|
||||||
|
|
||||||
|
public event Action<ThemeMode>? OnThemeChanged;
|
||||||
|
|
||||||
|
public ThemeService(IJSRuntime jsRuntime, IUserPreferenceStore userPreferenceStore)
|
||||||
{
|
{
|
||||||
_jsRuntime = jsRuntime;
|
_jsRuntime = jsRuntime;
|
||||||
|
_userPreferenceStore = userPreferenceStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
await _semaphore.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IsLightMode = await _jsRuntime.InvokeAsync<bool>("themeInterop.isLightMode");
|
if (_isInitialized) return;
|
||||||
if (OnThemeChanged != null) await OnThemeChanged();
|
|
||||||
|
ThemeMode localMode = ThemeMode.System;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cachedThemeVal = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "theme-mode");
|
||||||
|
if (Enum.TryParse<ThemeMode>(cachedThemeVal, out var parsedMode))
|
||||||
|
{
|
||||||
|
localMode = parsedMode;
|
||||||
|
}
|
||||||
|
else if (cachedThemeVal == "light" || cachedThemeVal == "theme-light")
|
||||||
|
{
|
||||||
|
localMode = ThemeMode.LightSepia;
|
||||||
|
}
|
||||||
|
else if (cachedThemeVal == "dark" || cachedThemeVal == "theme-dark")
|
||||||
|
{
|
||||||
|
localMode = ThemeMode.Dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
_systemPrefersLight = await _jsRuntime.InvokeAsync<bool>("themeInterop.isSystemLight");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silent catch for pre-rendering or unit tests
|
||||||
|
}
|
||||||
|
|
||||||
|
Mode = localMode;
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
await ApplyThemeToDomAsync(Mode);
|
||||||
|
|
||||||
|
// Asynchronously sync with the cloud to check for updates from other devices
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cloudResult = await _userPreferenceStore.GetThemePreferenceAsync();
|
||||||
|
if (cloudResult.IsSuccess && cloudResult.Value != Mode)
|
||||||
|
{
|
||||||
|
await SetThemeInternalAsync(cloudResult.Value, saveToCloud: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fail silently for background task/network errors
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch
|
finally
|
||||||
{
|
{
|
||||||
// Fail silently during prerendering or if JS is not available yet
|
_semaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetThemeAsync(ThemeMode mode)
|
||||||
|
{
|
||||||
|
await SetThemeInternalAsync(mode, saveToCloud: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetThemeInternalAsync(ThemeMode mode, bool saveToCloud)
|
||||||
|
{
|
||||||
|
await _semaphore.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Mode == mode && _isInitialized) return;
|
||||||
|
|
||||||
|
Mode = mode;
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
await ApplyThemeToDomAsync(mode);
|
||||||
|
|
||||||
|
OnThemeChanged?.Invoke(mode);
|
||||||
|
|
||||||
|
if (saveToCloud)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _userPreferenceStore.SaveThemePreferenceAsync(mode);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fail silently for background cloud sync errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_semaphore.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ToggleTheme()
|
public async Task ToggleTheme()
|
||||||
{
|
{
|
||||||
IsLightMode = !IsLightMode;
|
var nextMode = IsLightMode ? ThemeMode.Dark : ThemeMode.LightSepia;
|
||||||
|
await SetThemeAsync(nextMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyThemeToDomAsync(ThemeMode mode)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _jsRuntime.InvokeVoidAsync("themeInterop.setLightMode", IsLightMode);
|
string themeClass = "theme-dark"; // Default
|
||||||
|
if (mode == ThemeMode.LightSepia)
|
||||||
|
{
|
||||||
|
themeClass = "theme-light";
|
||||||
|
}
|
||||||
|
else if (mode == ThemeMode.System)
|
||||||
|
{
|
||||||
|
themeClass = _systemPrefersLight ? "theme-light" : "theme-dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
await _jsRuntime.InvokeVoidAsync("themeInterop.setCachedTheme", themeClass, ((int)mode).ToString());
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Fail silently
|
// Silent catch for pre-rendering
|
||||||
}
|
}
|
||||||
if (OnThemeChanged != null) await OnThemeChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,3 +21,4 @@
|
|||||||
@using NexusReader.Application.DTOs.User
|
@using NexusReader.Application.DTOs.User
|
||||||
@using NexusReader.Application.Queries.Reader
|
@using NexusReader.Application.Queries.Reader
|
||||||
@using NexusReader.Application.Queries.Recommendations
|
@using NexusReader.Application.Queries.Recommendations
|
||||||
|
@using NexusReader.Domain.Enums
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=Azeret+Mono:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--nexus-neon: #00ff99;
|
/* Semantic design tokens - default to Modern Deep Dark (Dark Mode) */
|
||||||
--nexus-neon-glow: rgba(0, 255, 153, 0.3);
|
--bg-base: #121214;
|
||||||
--nexus-bg: #121214;
|
--bg-surface: #1a1a1e;
|
||||||
--nexus-card: #1a1a1e;
|
--text-main: #ffffff;
|
||||||
--nexus-text: #ffffff;
|
--text-muted: #a1a1aa;
|
||||||
|
--accent: #00ff99;
|
||||||
|
--accent-glow: rgba(0, 255, 153, 0.3);
|
||||||
|
--border: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
/* Legacy mapping for backwards compatibility */
|
||||||
|
--nexus-neon: var(--accent);
|
||||||
|
--nexus-neon-glow: var(--accent-glow);
|
||||||
|
--nexus-bg: var(--bg-base);
|
||||||
|
--nexus-card: var(--bg-surface);
|
||||||
|
--nexus-text: var(--text-main);
|
||||||
--nexus-paper: #F9F9F9;
|
--nexus-paper: #F9F9F9;
|
||||||
--nexus-font-sans: 'Inter', sans-serif;
|
--nexus-font-sans: 'Inter', sans-serif;
|
||||||
--nexus-font-serif: 'Merriweather', serif;
|
--nexus-font-serif: 'Merriweather', serif;
|
||||||
|
--nexus-font-mono: 'Azeret Mono', monospace;
|
||||||
|
|
||||||
/* Global Selection Style Override */
|
/* Global Selection Style Override */
|
||||||
--nexus-selection: rgba(0, 255, 153, 0.25);
|
--nexus-selection: rgba(0, 255, 153, 0.25);
|
||||||
|
--nexus-accent: var(--accent);
|
||||||
|
|
||||||
/* Graph Nodes Theme Custom Properties (Dark Mode) */
|
/* Graph Nodes Theme Custom Properties (Dark Mode) */
|
||||||
--nexus-graph-bg: radial-gradient(circle, #1a1a1a 0%, #121212 100%);
|
--nexus-graph-bg: radial-gradient(circle, #1a1a1a 0%, #121212 100%);
|
||||||
@@ -49,38 +61,42 @@
|
|||||||
--nexus-node-concept-text: #e0e0e0;
|
--nexus-node-concept-text: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection,
|
||||||
background-color: var(--nexus-selection);
|
.ProseMirror ::selection,
|
||||||
color: inherit;
|
.ProseMirror::selection,
|
||||||
|
.ProseMirror *::selection {
|
||||||
|
background-color: var(--nexus-selection) !important;
|
||||||
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Global Semantic Theme Mapping */
|
/* Global Semantic Theme Mapping */
|
||||||
--nexus-primary: var(--nexus-neon);
|
:root {
|
||||||
--nexus-primary-glow: var(--nexus-neon-glow);
|
--nexus-primary: var(--nexus-neon);
|
||||||
--nexus-primary-hover: #00e688;
|
--nexus-primary-glow: var(--nexus-neon-glow);
|
||||||
|
--nexus-primary-hover: #00e688;
|
||||||
|
|
||||||
/* Standard Layout Tokens */
|
/* Standard Layout Tokens */
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
--radius-lg: 16px;
|
--radius-lg: 16px;
|
||||||
--radius-xl: 20px;
|
--radius-xl: 20px;
|
||||||
|
|
||||||
/* Safe Area Insets with fallbacks */
|
/* Safe Area Insets with fallbacks */
|
||||||
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global Glassmorphism with Fallback */
|
/* Global Glassmorphism with Fallback */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: rgba(20, 20, 20, 0.85);
|
background: rgba(20, 20, 20, 0.85);
|
||||||
/* Darker fallback for readability */
|
/* Darker fallback for readability */
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -133,11 +149,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.theme-dark {
|
||||||
|
/* Semantic design tokens - Modern Deep Dark */
|
||||||
|
--bg-base: #121214;
|
||||||
|
--bg-surface: #1a1a1e;
|
||||||
|
--text-main: #ffffff;
|
||||||
|
--text-muted: #a1a1aa;
|
||||||
|
--accent: #00ff99;
|
||||||
|
--accent-glow: rgba(0, 255, 153, 0.3);
|
||||||
|
--border: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
/* Legacy mapping for backwards compatibility */
|
||||||
|
--nexus-bg: var(--bg-base);
|
||||||
|
--nexus-card: var(--bg-surface);
|
||||||
|
--nexus-text: var(--text-main);
|
||||||
|
--nexus-selection: rgba(0, 255, 153, 0.25);
|
||||||
|
--nexus-accent: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.theme-light {
|
.theme-light {
|
||||||
--nexus-bg: #f4f1ea;
|
/* Semantic design tokens - Warm Paper / Soft Sepia */
|
||||||
--nexus-card: #ffffff;
|
--bg-base: #f4f1ea;
|
||||||
--nexus-text: #2d2a26;
|
--bg-surface: #ffffff;
|
||||||
|
--text-main: #2d2a26;
|
||||||
|
--text-muted: #78716c;
|
||||||
|
--accent: #10b981;
|
||||||
|
--accent-glow: rgba(16, 185, 129, 0.2);
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
/* Legacy mapping for backwards compatibility */
|
||||||
|
--nexus-bg: var(--bg-base);
|
||||||
|
--nexus-card: var(--bg-surface);
|
||||||
|
--nexus-text: var(--text-main);
|
||||||
--nexus-selection: rgba(16, 185, 129, 0.18);
|
--nexus-selection: rgba(16, 185, 129, 0.18);
|
||||||
|
--nexus-accent: var(--accent);
|
||||||
|
|
||||||
/* Graph Nodes Theme Custom Properties (Light Mode) */
|
/* Graph Nodes Theme Custom Properties (Light Mode) */
|
||||||
--nexus-graph-bg: radial-gradient(circle, #ffffff 0%, #e8e4da 100%);
|
--nexus-graph-bg: radial-gradient(circle, #ffffff 0%, #e8e4da 100%);
|
||||||
@@ -404,3 +449,546 @@ h1:focus {
|
|||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Selection Pop-up Menu & Crepe Toolbar Unification (SelectionAiPanel Style)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* 1. Pop-up Containers (Glassmorphism Capsule) */
|
||||||
|
.milkdown-popover,
|
||||||
|
.popover,
|
||||||
|
.prosemirror-bubble-menu,
|
||||||
|
.milkdown .popover,
|
||||||
|
.milkdown-popover.popover,
|
||||||
|
.milkdown-toolbar {
|
||||||
|
background: rgba(24, 24, 28, 0.85) !important;
|
||||||
|
backdrop-filter: blur(12px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(12px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 4px 6px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
display: inline-flex; /* Removed !important to allow Tippy.js to hide popovers via inline style */
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 4px !important;
|
||||||
|
z-index: 10000 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
animation: fadeInScaleGlobal 0.18s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light Theme (Warm Paper) Overrides for Container */
|
||||||
|
.theme-light .milkdown-popover,
|
||||||
|
.theme-light .popover,
|
||||||
|
.theme-light .prosemirror-bubble-menu,
|
||||||
|
.theme-light .milkdown .popover,
|
||||||
|
.theme-light .milkdown-popover.popover,
|
||||||
|
.theme-light .milkdown-toolbar {
|
||||||
|
background: rgba(254, 254, 254, 0.95) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Button & Item Formatting (Reader Selection Toolbar Style) */
|
||||||
|
.milkdown-popover button,
|
||||||
|
.popover button,
|
||||||
|
.prosemirror-bubble-menu button,
|
||||||
|
.milkdown .popover button,
|
||||||
|
.milkdown-toolbar button {
|
||||||
|
text-transform: none !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: #e4e4e7 !important; /* zinc-200 */
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crepe's Specific Square Icon-Only Toolbar Items */
|
||||||
|
.milkdown-toolbar .toolbar-item {
|
||||||
|
text-transform: none !important;
|
||||||
|
color: #e4e4e7 !important; /* zinc-200 */
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
padding: 6px !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon overrides inside buttons */
|
||||||
|
.milkdown-popover button svg,
|
||||||
|
.popover button svg,
|
||||||
|
.prosemirror-bubble-menu button svg,
|
||||||
|
.milkdown .popover button svg,
|
||||||
|
.milkdown-popover button i,
|
||||||
|
.popover button i,
|
||||||
|
.prosemirror-bubble-menu button i,
|
||||||
|
.milkdown .popover button i,
|
||||||
|
.milkdown-toolbar button svg,
|
||||||
|
.milkdown-toolbar .toolbar-item svg,
|
||||||
|
.milkdown-toolbar button i,
|
||||||
|
.milkdown-toolbar .toolbar-item i {
|
||||||
|
color: currentColor !important;
|
||||||
|
fill: currentColor !important;
|
||||||
|
width: 14px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects (zinc-200 / zinc-100 highlight) */
|
||||||
|
.milkdown-popover button:hover:not(.active),
|
||||||
|
.popover button:hover:not(.active),
|
||||||
|
.prosemirror-bubble-menu button:hover:not(.active),
|
||||||
|
.milkdown-toolbar button:hover:not(.active),
|
||||||
|
.milkdown-toolbar .toolbar-item:hover:not(.active) {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active formatting state colors (var(--accent, #00ff99)) */
|
||||||
|
.milkdown-popover button.active,
|
||||||
|
.popover button.active,
|
||||||
|
.prosemirror-bubble-menu button.active,
|
||||||
|
.milkdown-popover button[aria-pressed="true"],
|
||||||
|
.popover button[aria-pressed="true"],
|
||||||
|
.prosemirror-bubble-menu button[aria-pressed="true"],
|
||||||
|
.milkdown-toolbar button.active,
|
||||||
|
.milkdown-toolbar .toolbar-item.active,
|
||||||
|
.milkdown-toolbar button[aria-pressed="true"],
|
||||||
|
.milkdown-toolbar .toolbar-item[aria-pressed="true"] {
|
||||||
|
color: var(--accent, #00ff99) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-popover button.active:hover,
|
||||||
|
.popover button.active:hover,
|
||||||
|
.prosemirror-bubble-menu button.active:hover,
|
||||||
|
.milkdown-popover button[aria-pressed="true"]:hover,
|
||||||
|
.popover button[aria-pressed="true"]:hover,
|
||||||
|
.prosemirror-bubble-menu button[aria-pressed="true"]:hover,
|
||||||
|
.milkdown-toolbar button.active:hover,
|
||||||
|
.milkdown-toolbar .toolbar-item.active:hover,
|
||||||
|
.milkdown-toolbar button[aria-pressed="true"]:hover,
|
||||||
|
.milkdown-toolbar .toolbar-item[aria-pressed="true"]:hover {
|
||||||
|
background: rgba(0, 255, 153, 0.08) !important;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 153, 0.15) !important;
|
||||||
|
color: var(--accent, #00ff99) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Light Theme Overrides for Buttons */
|
||||||
|
.theme-light .milkdown-popover button,
|
||||||
|
.theme-light .popover button,
|
||||||
|
.theme-light .prosemirror-bubble-menu button,
|
||||||
|
.theme-light .milkdown-toolbar button,
|
||||||
|
.theme-light .milkdown-toolbar .toolbar-item {
|
||||||
|
color: #57524e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-popover button:hover:not(.active),
|
||||||
|
.theme-light .popover button:hover:not(.active),
|
||||||
|
.theme-light .prosemirror-bubble-menu button:hover:not(.active),
|
||||||
|
.theme-light .milkdown-toolbar button:hover:not(.active),
|
||||||
|
.theme-light .milkdown-toolbar .toolbar-item:hover:not(.active) {
|
||||||
|
background: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
color: #1c1917 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-popover button.active,
|
||||||
|
.theme-light .popover button.active,
|
||||||
|
.theme-light .prosemirror-bubble-menu button.active,
|
||||||
|
.theme-light .milkdown-popover button[aria-pressed="true"],
|
||||||
|
.theme-light .popover button[aria-pressed="true"],
|
||||||
|
.theme-light .prosemirror-bubble-menu button[aria-pressed="true"],
|
||||||
|
.theme-light .milkdown-toolbar button.active,
|
||||||
|
.theme-light .milkdown-toolbar .toolbar-item.active,
|
||||||
|
.theme-light .milkdown-toolbar button[aria-pressed="true"],
|
||||||
|
.theme-light .milkdown-toolbar .toolbar-item[aria-pressed="true"] {
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-popover button.active:hover,
|
||||||
|
.theme-light .popover button.active:hover,
|
||||||
|
.theme-light .prosemirror-bubble-menu button.active:hover,
|
||||||
|
.theme-light .milkdown-popover button[aria-pressed="true"]:hover,
|
||||||
|
.theme-light .popover button[aria-pressed="true"]:hover,
|
||||||
|
.theme-light .prosemirror-bubble-menu button[aria-pressed="true"]:hover,
|
||||||
|
.theme-light .milkdown-toolbar button.active:hover,
|
||||||
|
.theme-light .milkdown-toolbar .toolbar-item.active:hover,
|
||||||
|
.theme-light .milkdown-toolbar button[aria-pressed="true"]:hover,
|
||||||
|
.theme-light .milkdown-toolbar .toolbar-item[aria-pressed="true"]:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. Dividers Alignment */
|
||||||
|
.milkdown-popover .divider,
|
||||||
|
.popover .divider,
|
||||||
|
.prosemirror-bubble-menu .divider,
|
||||||
|
.milkdown .popover .divider,
|
||||||
|
.milkdown-toolbar .divider {
|
||||||
|
width: 1px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
margin: 0 4px !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-popover .divider,
|
||||||
|
.theme-light .popover .divider,
|
||||||
|
.theme-light .prosemirror-bubble-menu .divider,
|
||||||
|
.theme-light .milkdown-toolbar .divider {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Editor Table & General Control Elements Theming (SelectionAiPanel Style)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* 1. Cell Drag Handles (Column and Row Drag Handles) */
|
||||||
|
.milkdown .milkdown-table-block .cell-handle {
|
||||||
|
background-color: rgba(24, 24, 28, 0.85) !important;
|
||||||
|
backdrop-filter: blur(12px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(12px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
color: #e4e4e7 !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .cell-handle {
|
||||||
|
background-color: rgba(254, 254, 254, 0.95) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
color: #57524e !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .cell-handle:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .cell-handle:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08) !important;
|
||||||
|
color: #1c1917 !important;
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .cell-handle svg {
|
||||||
|
fill: currentColor !important;
|
||||||
|
color: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Drag Handle Options Popup Menu (.button-group) */
|
||||||
|
.milkdown .milkdown-table-block .cell-handle .button-group {
|
||||||
|
background: rgba(24, 24, 28, 0.85) !important;
|
||||||
|
backdrop-filter: blur(12px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(12px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
padding: 4px !important;
|
||||||
|
gap: 4px !important;
|
||||||
|
display: flex; /* Removed !important to allow toggling via data-show attribute */
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group {
|
||||||
|
background: rgba(254, 254, 254, 0.95) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .cell-handle .button-group button {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
padding: 6px !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
color: #e4e4e7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group button {
|
||||||
|
color: #57524e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .cell-handle .button-group button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
color: #1c1917 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .cell-handle .button-group button:active {
|
||||||
|
background: rgba(0, 255, 153, 0.08) !important;
|
||||||
|
color: var(--accent, #00ff99) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group button:active {
|
||||||
|
background: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .cell-handle .button-group button svg {
|
||||||
|
color: currentColor !important;
|
||||||
|
fill: currentColor !important;
|
||||||
|
width: 14px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Table Column/Row Insertion Lines & Add Buttons */
|
||||||
|
.milkdown .milkdown-table-block .line-handle {
|
||||||
|
background-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .line-handle {
|
||||||
|
background-color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .line-handle .add-button {
|
||||||
|
background-color: rgba(24, 24, 28, 0.85) !important;
|
||||||
|
backdrop-filter: blur(12px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(12px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
color: #e4e4e7 !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .line-handle .add-button {
|
||||||
|
background-color: rgba(254, 254, 254, 0.95) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
color: #57524e !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .line-handle .add-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-table-block .line-handle .add-button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08) !important;
|
||||||
|
color: #1c1917 !important;
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-table-block .line-handle .add-button svg {
|
||||||
|
width: 12px !important;
|
||||||
|
height: 12px !important;
|
||||||
|
color: currentColor !important;
|
||||||
|
fill: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. Paragraph Block Drag Handles */
|
||||||
|
.milkdown .milkdown-block-handle {
|
||||||
|
background-color: rgba(24, 24, 28, 0.85) !important;
|
||||||
|
backdrop-filter: blur(12px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(12px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
padding: 2px !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
display: flex; /* Removed !important to allow toggling */
|
||||||
|
gap: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-block-handle {
|
||||||
|
background-color: rgba(254, 254, 254, 0.95) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-block-handle .operation-item {
|
||||||
|
border-radius: 4px !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
padding: 4px !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
color: #e4e4e7 !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-block-handle .operation-item {
|
||||||
|
color: #57524e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-block-handle .operation-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-block-handle .operation-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
color: #1c1917 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-block-handle .operation-item.active {
|
||||||
|
background: rgba(0, 255, 153, 0.08) !important;
|
||||||
|
color: var(--accent, #00ff99) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-block-handle .operation-item.active {
|
||||||
|
background: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-block-handle .operation-item svg {
|
||||||
|
width: 14px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
fill: currentColor !important;
|
||||||
|
color: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. Slash Commands Menu */
|
||||||
|
.milkdown .milkdown-slash-menu {
|
||||||
|
background: rgba(24, 24, 28, 0.85) !important;
|
||||||
|
backdrop-filter: blur(12px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(12px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
font-family: var(--nexus-font-sans) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu {
|
||||||
|
background: rgba(254, 254, 254, 0.95) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04) !important;
|
||||||
|
color: #2d2a26 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .tab-group ul li {
|
||||||
|
color: #a1a1aa !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .tab-group ul li {
|
||||||
|
color: #78716c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .tab-group ul li:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .tab-group ul li:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
color: #1c1917 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .tab-group ul li.selected {
|
||||||
|
background: rgba(0, 255, 153, 0.08) !important;
|
||||||
|
color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .tab-group ul li.selected {
|
||||||
|
background: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .menu-groups .menu-group li {
|
||||||
|
color: #e4e4e7 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li {
|
||||||
|
color: #57524e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .menu-groups .menu-group li svg {
|
||||||
|
color: #a1a1aa !important;
|
||||||
|
fill: #a1a1aa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li svg {
|
||||||
|
color: #78716c !important;
|
||||||
|
fill: #78716c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .menu-groups .menu-group li.hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li.hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
color: #1c1917 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .menu-groups .menu-group li.active {
|
||||||
|
background: rgba(0, 255, 153, 0.08) !important;
|
||||||
|
color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li.active {
|
||||||
|
background: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown .milkdown-slash-menu .menu-groups .menu-group li.active svg {
|
||||||
|
color: var(--accent) !important;
|
||||||
|
fill: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li.active svg {
|
||||||
|
color: #10b981 !important;
|
||||||
|
fill: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6. Explicit Visibility State Overrides */
|
||||||
|
.milkdown-popover[data-show="false"],
|
||||||
|
.popover[data-show="false"],
|
||||||
|
.prosemirror-bubble-menu[data-show="false"],
|
||||||
|
.milkdown-toolbar[data-show="false"],
|
||||||
|
.milkdown-slash-menu[data-show="false"],
|
||||||
|
.milkdown-table-block .cell-handle .button-group[data-show="false"],
|
||||||
|
.milkdown-link-preview[data-show="false"],
|
||||||
|
.milkdown-link-edit[data-show="false"] {
|
||||||
|
display: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 7. Table Overflow Clipping Fix for Handles */
|
||||||
|
.milkdown .tableWrapper {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,244 @@
|
|||||||
|
// Initialize global stores on window to share state across dynamically imported module instances (preventing cache-buster isolation)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (!window.editorCache) window.editorCache = new Map();
|
||||||
|
if (!window.editorStates) window.editorStates = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorCache = typeof window !== 'undefined' ? window.editorCache : new Map();
|
||||||
|
const editorStates = typeof window !== 'undefined' ? window.editorStates : new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously injects a stylesheet link tag into the document head
|
||||||
|
* and returns a Promise that resolves when the stylesheet is fully loaded.
|
||||||
|
*/
|
||||||
|
async function ensureStylesheet(href) {
|
||||||
|
if (document.querySelector(`link[href="${href}"]`)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = href;
|
||||||
|
link.onload = () => resolve();
|
||||||
|
link.onerror = (err) => reject(new Error(`Failed to load stylesheet: ${href}. ${err}`));
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes a Milkdown Crepe editor on the specified element.
|
||||||
|
*/
|
||||||
|
export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
|
||||||
|
// Check if already destroyed or initializing
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] initEditor called on already destroyed element: ${elementId}. Aborting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editorStates.get(elementId) === 'initializing' || editorStates.get(elementId) === 'ready') {
|
||||||
|
console.warn(`[Milkdown] Editor is already initializing or ready for element: ${elementId}. Ignoring.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorStates.set(elementId, 'initializing');
|
||||||
|
|
||||||
|
// Guard 1: Destroy previous cached editor instance with the same ID if it exists
|
||||||
|
if (editorCache.has(elementId)) {
|
||||||
|
console.warn(`[Milkdown] Editor instance already exists in cache for: ${elementId}. Destroying first.`);
|
||||||
|
await destroyEditor(elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById(elementId);
|
||||||
|
if (!container) {
|
||||||
|
console.error(`[Milkdown] Container with ID "${elementId}" not found.`);
|
||||||
|
editorStates.delete(elementId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 2: Clear container children to prevent double-initialization of crepe editor DOM
|
||||||
|
if (container.children.length > 0) {
|
||||||
|
console.warn(`[Milkdown] Container "${elementId}" is not empty. Clearing children before initialization.`);
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 3: Search the parent workspace card to purge any other leftover editor components
|
||||||
|
const parentCard = container.closest('.milkdown-premium-container') || container.parentElement;
|
||||||
|
if (parentCard) {
|
||||||
|
const existingEditors = parentCard.querySelectorAll('.milkdown, .crepe');
|
||||||
|
if (existingEditors.length > 0) {
|
||||||
|
console.warn(`[Milkdown] Found ${existingEditors.length} leftover editor DOM elements in the workspace card. Purging them.`);
|
||||||
|
existingEditors.forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor
|
||||||
|
await ensureStylesheet('/_content/NexusReader.UI.Shared/css/vendor/milkdown-crepe.css');
|
||||||
|
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] Element ${elementId} destroyed during stylesheet loading. Aborting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically import the local JS bundle
|
||||||
|
await import('/_content/NexusReader.UI.Shared/js/vendor/milkdown-crepe.js');
|
||||||
|
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] Element ${elementId} destroyed during crepe bundle loading. Aborting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Crepe constructor from the global window.milkdownCrepe namespace
|
||||||
|
const Crepe = window.milkdownCrepe?.Crepe;
|
||||||
|
if (!Crepe) {
|
||||||
|
throw new Error("Crepe constructor not found on window.milkdownCrepe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the Crepe editor instance with custom ImageBlock upload handler
|
||||||
|
const crepe = new Crepe({
|
||||||
|
root: container,
|
||||||
|
defaultValue: initialMarkdown || "",
|
||||||
|
featureConfigs: {
|
||||||
|
[Crepe.Feature.ImageBlock]: {
|
||||||
|
onUpload: async (file) => {
|
||||||
|
try {
|
||||||
|
const streamRef = DotNet.createJSStreamReference(file);
|
||||||
|
const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef);
|
||||||
|
return url;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Milkdown] Failed to upload image from JS (onUpload):", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure custom uploader using the uploadConfig context slice
|
||||||
|
crepe.editor.config((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.update('uploadConfig', (prev) => ({
|
||||||
|
...prev,
|
||||||
|
uploader: async (files, schema) => {
|
||||||
|
const nodes = [];
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
const streamRef = DotNet.createJSStreamReference(file);
|
||||||
|
const uploadedUrl = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef);
|
||||||
|
if (uploadedUrl) {
|
||||||
|
const node = schema.nodes.image.create({ src: uploadedUrl, alt: file.name });
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Milkdown] Failed to upload image in custom uploader:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Milkdown] Failed to configure uploadConfig uploader:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook into the Crepe content update listener system with 300ms JS debounce
|
||||||
|
let debounceTimeout = null;
|
||||||
|
crepe.on((listener) => {
|
||||||
|
listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
|
||||||
|
if (debounceTimeout) {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
}
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') return;
|
||||||
|
dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown)
|
||||||
|
.catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err));
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the editor instance in the map
|
||||||
|
editorCache.set(elementId, crepe);
|
||||||
|
|
||||||
|
// Create the editor view asynchronously
|
||||||
|
await crepe.create();
|
||||||
|
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] Element ${elementId} destroyed during crepe.create(). Cleaning up.`);
|
||||||
|
await crepe.destroy();
|
||||||
|
editorCache.delete(elementId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorStates.set(elementId, 'ready');
|
||||||
|
console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`);
|
||||||
|
} catch (error) {
|
||||||
|
editorStates.delete(elementId);
|
||||||
|
console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the current Markdown content from a specific editor instance.
|
||||||
|
*/
|
||||||
|
export function getMarkdownContent(elementId) {
|
||||||
|
const crepe = editorCache.get(elementId);
|
||||||
|
if (!crepe) {
|
||||||
|
console.warn(`[Milkdown] No editor instance found for element: ${elementId}`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return crepe.getMarkdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely disposes of the editor instance to prevent memory leaks in WASM.
|
||||||
|
*/
|
||||||
|
export async function destroyEditor(elementId) {
|
||||||
|
editorStates.set(elementId, 'destroyed');
|
||||||
|
|
||||||
|
const crepe = editorCache.get(elementId);
|
||||||
|
if (crepe) {
|
||||||
|
try {
|
||||||
|
await crepe.destroy();
|
||||||
|
console.log(`[Milkdown] Editor instance successfully destroyed: ${elementId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Milkdown] Error destroying editor for element "${elementId}":`, error);
|
||||||
|
}
|
||||||
|
editorCache.delete(elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly clean up container DOM children
|
||||||
|
const container = document.getElementById(elementId);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely retrieves all localStorage keys starting with the "nexus-bkp-" prefix.
|
||||||
|
*/
|
||||||
|
export function getBackupKeys() {
|
||||||
|
const keys = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith('nexus-bkp-')) {
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Milkdown] Error listing localStorage keys:", err);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to window for global access (especially from DisposeAsync when module reference is null)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.milkdownWrapper = {
|
||||||
|
initEditor,
|
||||||
|
getMarkdownContent,
|
||||||
|
destroyEditor,
|
||||||
|
getBackupKeys
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
|
|||||||
if (!container) return null;
|
if (!container) return null;
|
||||||
|
|
||||||
let isThrottled = false;
|
let isThrottled = false;
|
||||||
|
let lastScrollTop = 0;
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (isThrottled) return;
|
if (isThrottled) return;
|
||||||
@@ -44,6 +45,17 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
|
|||||||
// Ensure bounds
|
// Ensure bounds
|
||||||
percentage = Math.max(0, Math.min(100, percentage));
|
percentage = Math.max(0, Math.min(100, percentage));
|
||||||
|
|
||||||
|
// Scroll delta detection:
|
||||||
|
// Hide bars on scroll down, show on scroll up. Force show when close to top.
|
||||||
|
const delta = scrollTop - lastScrollTop;
|
||||||
|
if (scrollTop <= 10) {
|
||||||
|
dotNetHelper.invokeMethodAsync('HandleScrollDelta', false);
|
||||||
|
} else if (Math.abs(delta) > 5) {
|
||||||
|
const hideBars = delta > 0;
|
||||||
|
dotNetHelper.invokeMethodAsync('HandleScrollDelta', hideBars);
|
||||||
|
}
|
||||||
|
lastScrollTop = scrollTop;
|
||||||
|
|
||||||
dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage);
|
dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage);
|
||||||
isThrottled = false;
|
isThrottled = false;
|
||||||
});
|
});
|
||||||
@@ -60,3 +72,4 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
window.themeInterop = {
|
window.themeInterop = {
|
||||||
|
isSystemLight: function () {
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||||
|
},
|
||||||
|
setCachedTheme: function (themeClass, modeValue) {
|
||||||
|
localStorage.setItem('theme-mode', modeValue);
|
||||||
|
localStorage.setItem('theme', themeClass === 'theme-light' ? 'light' : 'dark');
|
||||||
|
|
||||||
|
if (themeClass === 'theme-light') {
|
||||||
|
document.documentElement.classList.add('theme-light');
|
||||||
|
document.documentElement.classList.remove('theme-dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('theme-dark');
|
||||||
|
document.documentElement.classList.remove('theme-light');
|
||||||
|
}
|
||||||
|
},
|
||||||
isLightMode: function () {
|
isLightMode: function () {
|
||||||
return document.documentElement.classList.contains('theme-light');
|
return document.documentElement.classList.contains('theme-light');
|
||||||
},
|
},
|
||||||
setLightMode: function (isLight) {
|
setLightMode: function (isLight) {
|
||||||
if (isLight) {
|
var themeClass = isLight ? 'theme-light' : 'theme-dark';
|
||||||
document.documentElement.classList.add('theme-light');
|
var modeValue = isLight ? '2' : '1';
|
||||||
localStorage.setItem('theme', 'light');
|
this.setCachedTheme(themeClass, modeValue);
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('theme-light');
|
|
||||||
localStorage.setItem('theme', 'dark');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -17,7 +17,8 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
|||||||
// Platform & UI Services
|
// Platform & UI Services
|
||||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
|
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddSingleton<IUserPreferenceStore, CloudUserPreferenceStore>();
|
||||||
|
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||||
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||||
builder.Services.AddSingleton(featureSettings);
|
builder.Services.AddSingleton(featureSettings);
|
||||||
@@ -61,6 +62,10 @@ builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepos
|
|||||||
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
|
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
|
||||||
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
||||||
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
|
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
|
||||||
|
builder.Services.AddSingleton<IUserLibraryStore>(new ThrowingUserLibraryStore());
|
||||||
|
builder.Services.AddSingleton<IVectorSearchStore>(new ThrowingVectorSearchStore());
|
||||||
|
builder.Services.Configure<NexusReader.Application.Common.RagMonetizationOptions>(builder.Configuration.GetSection(NexusReader.Application.Common.RagMonetizationOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton<IChatClient>(new ThrowingChatClient());
|
||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
|
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
|
||||||
@@ -134,3 +139,37 @@ public class ThrowingEpubExtractor : IEpubExtractor
|
|||||||
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
|
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ThrowingUserLibraryStore : IUserLibraryStore
|
||||||
|
{
|
||||||
|
public Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
|
||||||
|
|
||||||
|
public Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ThrowingVectorSearchStore : IVectorSearchStore
|
||||||
|
{
|
||||||
|
public Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
|
||||||
|
|
||||||
|
public Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
|
||||||
|
|
||||||
|
public Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ThrowingChatClient : IChatClient
|
||||||
|
{
|
||||||
|
public void Dispose() { }
|
||||||
|
|
||||||
|
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
|
||||||
|
|
||||||
|
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
|
||||||
|
|
||||||
|
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.Web.Client.Services;
|
||||||
|
|
||||||
|
public class CloudUserPreferenceStore : IUserPreferenceStore
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
public CloudUserPreferenceStore(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
|
||||||
|
|
||||||
|
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return Result.Fail($"Failed to save cloud theme preference: {error}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Network error saving theme preference to cloud.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var response = await client.GetAsync("identity/profile");
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
|
return profile != null
|
||||||
|
? Result.Ok(profile.ThemePreference)
|
||||||
|
: Result.Fail("Failed to deserialize profile response.");
|
||||||
|
}
|
||||||
|
return Result.Fail($"Failed to fetch theme preference from cloud: {response.ReasonPhrase}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Network error retrieving theme preference from cloud.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,28 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const savedTheme = localStorage.getItem('theme');
|
try {
|
||||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
var themeMode = localStorage.getItem('theme-mode');
|
||||||
const isLight = savedTheme === 'light' || (!savedTheme && !systemPrefersDark);
|
var savedTheme = localStorage.getItem('theme');
|
||||||
if (isLight) {
|
var isLight = false;
|
||||||
document.documentElement.classList.add('theme-light');
|
|
||||||
} else {
|
if (themeMode === '2' || savedTheme === 'light') {
|
||||||
document.documentElement.classList.remove('theme-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>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using NexusReader.Web.Components;
|
using NexusReader.Web.Components;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
@@ -52,7 +53,9 @@ builder.Services.AddHttpContextAccessor();
|
|||||||
|
|
||||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IUserPreferenceStore, NexusReader.Web.Services.ServerUserPreferenceStore>();
|
||||||
|
builder.Services.AddScoped<IThemeService, NexusReader.Web.Services.ServerThemeService>();
|
||||||
|
builder.Services.AddScoped<IRecommendationService, NexusReader.Web.Services.ServerRecommendationService>();
|
||||||
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||||
builder.Services.AddSingleton(featureSettings);
|
builder.Services.AddSingleton(featureSettings);
|
||||||
@@ -89,6 +92,10 @@ builder.Services.AddCascadingAuthenticationState();
|
|||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddCheck<NexusReader.Web.Services.DatabaseHealthCheck>("Database")
|
||||||
|
.AddCheck<NexusReader.Web.Services.QdrantHealthCheck>("Qdrant")
|
||||||
|
.AddCheck<NexusReader.Web.Services.Neo4jHealthCheck>("Neo4j");
|
||||||
|
|
||||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||||
NexusReader.Application.DependencyInjection.Assembly,
|
NexusReader.Application.DependencyInjection.Assembly,
|
||||||
@@ -115,6 +122,17 @@ builder.Services.AddAuthentication(options =>
|
|||||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
||||||
})
|
})
|
||||||
|
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.Authority = builder.Configuration["Jwt:Authority"] ?? "https://example.com/";
|
||||||
|
options.Audience = builder.Configuration["Jwt:Audience"] ?? "NexusReaderAPI";
|
||||||
|
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true
|
||||||
|
};
|
||||||
|
})
|
||||||
.AddGoogle(options =>
|
.AddGoogle(options =>
|
||||||
{
|
{
|
||||||
options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id";
|
options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id";
|
||||||
@@ -293,6 +311,7 @@ if (!allowRegistration || !allowPasswordReset)
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||||
|
|
||||||
// API endpoint for WASM client to fetch EPUB content
|
// API endpoint for WASM client to fetch EPUB content
|
||||||
@@ -491,6 +510,132 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator
|
|||||||
return Results.BadRequest(errorMsg);
|
return Results.BadRequest(errorMsg);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/creator/dashboard", async (ClaimsPrincipal user, IMediator mediator) =>
|
||||||
|
{
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
|
||||||
|
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetCreatorDashboardDataQuery(userId, tenantId));
|
||||||
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
|
|
||||||
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
|
return Results.BadRequest(errorMsg);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, ClaimsPrincipal user, IMediator mediator) =>
|
||||||
|
{
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
|
||||||
|
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
|
||||||
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
|
|
||||||
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
|
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.NotFound(errorMsg);
|
||||||
|
}
|
||||||
|
return Results.BadRequest(errorMsg);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
|
||||||
|
{
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Version string is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
|
||||||
|
if (result.IsSuccess) return Results.Ok();
|
||||||
|
|
||||||
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
|
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.NotFound(errorMsg);
|
||||||
|
}
|
||||||
|
return Results.BadRequest(errorMsg);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPost("/api/creator/books", async (
|
||||||
|
[FromBody] NexusReader.Application.DTOs.Creator.CreateBookRequestDto request,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
IMediator mediator) =>
|
||||||
|
{
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
|
||||||
|
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.CreateBookCommand(
|
||||||
|
request.Title,
|
||||||
|
request.Description,
|
||||||
|
userId,
|
||||||
|
tenantId
|
||||||
|
));
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
return Results.Ok(new NexusReader.Application.DTOs.Creator.CreateBookResponseDto(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
|
return Results.BadRequest(errorMsg);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/creator/books/{bookId:guid}/chapters", async (Guid bookId, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||||
|
{
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
var book = await dbContext.Books
|
||||||
|
.Include(b => b.CurrentDraftRevision)
|
||||||
|
.ThenInclude(r => r!.Chapters)
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == bookId && b.UserId == userId);
|
||||||
|
|
||||||
|
if (book == null) return Results.NotFound();
|
||||||
|
if (book.CurrentDraftRevision == null) return Results.BadRequest("No active draft revision.");
|
||||||
|
|
||||||
|
var chapters = book.CurrentDraftRevision.Chapters
|
||||||
|
.OrderBy(c => c.SortOrder)
|
||||||
|
.Select(c => new { c.Id, c.Title, c.SortOrder })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(chapters);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/chapters/{id:guid}", async (Guid id, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||||
|
{
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var chapter = await dbContext.Chapters
|
||||||
|
.Include(c => c.BookRevision)
|
||||||
|
.ThenInclude(r => r.Book)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
if (chapter == null) return Results.NotFound();
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (chapter.BookRevision.Book.UserId != userId)
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { chapter.Id, chapter.Title, chapter.MarkdownContent });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/library/purchase", async (
|
app.MapPost("/api/library/purchase", async (
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
[FromBody] PurchaseBookRequest request,
|
[FromBody] PurchaseBookRequest request,
|
||||||
@@ -753,6 +898,99 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator)
|
|||||||
return Results.Ok(result.Value);
|
return Results.Ok(result.Value);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPost("/identity/theme", async (
|
||||||
|
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.User.UpdateThemeRequest request,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
IMediator mediator) =>
|
||||||
|
{
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var result = await mediator.Send(new NexusReader.Application.Commands.User.UpdateThemeCommand(userId, request.Mode));
|
||||||
|
if (result.IsFailed) return Results.BadRequest(result.Errors.FirstOrDefault()?.Message);
|
||||||
|
|
||||||
|
return Results.Ok();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPost("/api/media/upload", async (
|
||||||
|
HttpRequest request,
|
||||||
|
NexusReader.Application.Abstractions.Services.IStorageService storageService,
|
||||||
|
ILogger<Program> logger) =>
|
||||||
|
{
|
||||||
|
if (!request.HasFormContentType)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Request must be a multipart form.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = await request.ReadFormAsync();
|
||||||
|
var file = form.Files.GetFile("file");
|
||||||
|
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("No file uploaded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size limit check (max 5MB)
|
||||||
|
const long maxFileSize = 5 * 1024 * 1024;
|
||||||
|
if (file.Length > maxFileSize)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("File size exceeds the 5MB limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file bytes for signature check
|
||||||
|
byte[] fileBytes;
|
||||||
|
using (var memoryStream = new MemoryStream())
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(memoryStream);
|
||||||
|
fileBytes = memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate signature without trusting browser content-type, enforcing extension matching
|
||||||
|
if (!ImageValidator.ValidateImageSignature(fileBytes, file.FileName, out var detectedContentType))
|
||||||
|
{
|
||||||
|
logger.LogWarning("File signature validation failed for file {FileName} with browser content type {ContentType}.", file.FileName, file.ContentType);
|
||||||
|
return Results.BadRequest("Invalid file signature or extension mismatch. Legitimate JPEG, PNG, WEBP, or GIF images only.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save using IStorageService with the verified content type
|
||||||
|
var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, detectedContentType);
|
||||||
|
return Results.Ok(new NexusReader.Application.DTOs.Media.UploadResultDto(fileUrl));
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapPost("/api/chapters/validate", (
|
||||||
|
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.ValidateChapterRequest request,
|
||||||
|
NexusReader.Application.Abstractions.Services.ISanitizerService sanitizerService) =>
|
||||||
|
{
|
||||||
|
if (request == null || string.IsNullOrEmpty(request.Content))
|
||||||
|
{
|
||||||
|
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitized = sanitizerService.Sanitize(request.Content);
|
||||||
|
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapPut("/api/chapters/{id:guid}/autosave", async (
|
||||||
|
Guid id,
|
||||||
|
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.AutosaveChapterRequest request,
|
||||||
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
|
ILoggerFactory loggerFactory) =>
|
||||||
|
{
|
||||||
|
var logger = loggerFactory.CreateLogger("ChaptersApi");
|
||||||
|
logger.LogInformation("Autosaving chapter {ChapterId} with content length {Length}", id, request?.MarkdownContent?.Length ?? 0);
|
||||||
|
|
||||||
|
if (request == null) return Results.BadRequest("Request content cannot be null.");
|
||||||
|
|
||||||
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
var chapter = await dbContext.Chapters.FindAsync(id);
|
||||||
|
if (chapter == null) return Results.NotFound($"Chapter with ID '{id}' was not found.");
|
||||||
|
|
||||||
|
chapter.MarkdownContent = request.MarkdownContent;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { Success = true });
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode()
|
||||||
.AddInteractiveWebAssemblyRenderMode()
|
.AddInteractiveWebAssemblyRenderMode()
|
||||||
@@ -804,6 +1042,58 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ImageValidator
|
||||||
|
{
|
||||||
|
public static bool ValidateImageSignature(byte[] bytes, string fileName, out string detectedContentType)
|
||||||
|
{
|
||||||
|
detectedContentType = string.Empty;
|
||||||
|
if (bytes.Length < 4) return false;
|
||||||
|
|
||||||
|
// Check PNG signature: 89 50 4E 47
|
||||||
|
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
|
||||||
|
{
|
||||||
|
detectedContentType = "image/png";
|
||||||
|
}
|
||||||
|
// Check JPEG signature: FF D8 FF
|
||||||
|
else if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
|
||||||
|
{
|
||||||
|
detectedContentType = "image/jpeg";
|
||||||
|
}
|
||||||
|
// Check WEBP signature: RIFF ... WEBP
|
||||||
|
else if (bytes.Length >= 12 &&
|
||||||
|
bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF
|
||||||
|
bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP
|
||||||
|
{
|
||||||
|
detectedContentType = "image/webp";
|
||||||
|
}
|
||||||
|
// Check GIF signature: GIF87a or GIF89a
|
||||||
|
else if (bytes.Length >= 6 &&
|
||||||
|
bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 &&
|
||||||
|
(bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61)
|
||||||
|
{
|
||||||
|
detectedContentType = "image/gif";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(detectedContentType))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the file extension matches the detected content type (extension-spoofing guard)
|
||||||
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
var isMatch = detectedContentType switch
|
||||||
|
{
|
||||||
|
"image/png" => ext == ".png",
|
||||||
|
"image/jpeg" => ext == ".jpg" || ext == ".jpeg",
|
||||||
|
"image/webp" => ext == ".webp",
|
||||||
|
"image/gif" => ext == ".gif",
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
return isMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record KnowledgeRequest(string Text, Guid? EbookId = null);
|
public record KnowledgeRequest(string Text, Guid? EbookId = null);
|
||||||
public record GroundednessRequest(string Answer, string Context);
|
public record GroundednessRequest(string Answer, string Context);
|
||||||
public record SemanticSearchRequest(string QueryText, int Limit = 5);
|
public record SemanticSearchRequest(string QueryText, int Limit = 5);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class DatabaseHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _dbContext;
|
||||||
|
|
||||||
|
public DatabaseHealthCheck(AppDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
|
||||||
|
if (canConnect)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Healthy("Database is accessible.");
|
||||||
|
}
|
||||||
|
return HealthCheckResult.Unhealthy("Cannot connect to the database.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Database health check failed with exception.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Neo4j.Driver;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class Neo4jHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IDriver _driver;
|
||||||
|
|
||||||
|
public Neo4jHealthCheck(IDriver driver)
|
||||||
|
{
|
||||||
|
_driver = driver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _driver.VerifyConnectivityAsync();
|
||||||
|
return HealthCheckResult.Healthy("Neo4j database is accessible.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Neo4j database connectivity check failed.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Qdrant.Client;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class QdrantHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly QdrantClient _qdrantClient;
|
||||||
|
|
||||||
|
public QdrantHealthCheck(QdrantClient qdrantClient)
|
||||||
|
{
|
||||||
|
_qdrantClient = qdrantClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Simple check: query collection existence to verify connection is alive
|
||||||
|
_ = await _qdrantClient.CollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
|
return HealthCheckResult.Healthy("Qdrant database is accessible.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Qdrant database health check failed.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using NexusReader.Application.Queries.Recommendations;
|
||||||
|
using NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side implementation of <see cref="IRecommendationService"/> that executes
|
||||||
|
/// the MediatR query directly inside the Web Server's request context.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerRecommendationService : IRecommendationService
|
||||||
|
{
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
|
public ServerRecommendationService(IMediator mediator, IHttpContextAccessor httpContextAccessor)
|
||||||
|
{
|
||||||
|
_mediator = mediator;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var httpContext = _httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext?.User == null)
|
||||||
|
{
|
||||||
|
return new List<RecommendationDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
return new List<RecommendationDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _mediator.Send(new GetContextualRecommendationsQuery(userId), cancellationToken);
|
||||||
|
if (result.IsSuccess && result.Value != null)
|
||||||
|
{
|
||||||
|
return result.Value.Recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<RecommendationDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
using NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public sealed class ServerThemeService : IThemeService
|
||||||
|
{
|
||||||
|
public ThemeMode Mode => ThemeMode.System;
|
||||||
|
public bool IsLightMode => false;
|
||||||
|
|
||||||
|
// Explicit event implementation to avoid CS0067 warning about unused events on the server
|
||||||
|
public event Action<ThemeMode>? OnThemeChanged
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InitializeAsync() => Task.CompletedTask;
|
||||||
|
public Task SetThemeAsync(ThemeMode mode) => Task.CompletedTask;
|
||||||
|
public Task ToggleTheme() => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using FluentResults;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class ServerUserPreferenceStore : IUserPreferenceStore
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
|
public ServerUserPreferenceStore(
|
||||||
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
|
IHttpContextAccessor httpContextAccessor)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
|
||||||
|
{
|
||||||
|
var userId = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
return Result.Fail("User is not authenticated on the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||||
|
var user = await dbContext.Users
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Result.Fail("User not found in database.");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ThemePreference = mode;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Database failure updating theme preference.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
|
||||||
|
{
|
||||||
|
var userId = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
return Result.Fail("User is not authenticated on the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||||
|
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Result.Fail("User not found in database.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok(user.ThemePreference);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Database failure reading theme preference.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user