1 Commits

91 changed files with 782 additions and 3088 deletions
@@ -29,12 +29,9 @@ This skill defines the architectural guardrails for the NexusReader project to e
- Use `FluentResults` (`Result<T>`) for all Application services and handlers. - Use `FluentResults` (`Result<T>`) for all Application services and handlers.
- Avoid throwing exceptions for expected business failures; use `Result.Fail()`. - Avoid throwing exceptions for expected business failures; use `Result.Fail()`.
### 4. MediatR Patterns
- **Queries**: Read-only operations. Should return `Result<T>`. Use `AsNoTracking()` in EF Core.
- **Commands**: State-changing operations. Should return `Result` or `Result<T>`. - **Commands**: State-changing operations. Should return `Result` or `Result<T>`.
+
+### 5. Async Operations (Zero Tolerance for `async void`)
+- All asynchronous operations MUST return `Task` or `ValueTask`.
+- Event handlers MUST use `Func<Task>` or async-compatible patterns.
+- UI components MUST await all service calls and use `InvokeAsync(StateHasChanged)` for state updates within async contexts.
## Audit Scripts ## Audit Scripts
- [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports. - [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports.
-3
View File
@@ -8,7 +8,4 @@ description: D3.js standards for Knowledge Graph
- **JS Interop:** Use ES6 modules and `IJSObjectReference`. - **JS Interop:** Use ES6 modules and `IJSObjectReference`.
- **Responsiveness:** SVG must use `viewBox` for fluid portrait scaling. - **Responsiveness:** SVG must use `viewBox` for fluid portrait scaling.
- **Visuals:** Use CSS variables (`--nexus-neon`) for node styling. - **Visuals:** Use CSS variables (`--nexus-neon`) for node styling.
- **Transitions:** Enforce smooth 500ms transitions using the D3.js General Update Pattern (`.join()`).
- **Animations:** Implement "Neon Flash" entry animations for newly discovered knowledge nodes.
- **Contextual Highlight:** Support node/link dimming to emphasize the current reading context.
- **Events:** JS emits events (like `nodeClicked`) caught by Blazor via `DotNetObjectReference`. - **Events:** JS emits events (like `nodeClicked`) caught by Blazor via `DotNetObjectReference`.
+1 -6
View File
@@ -22,7 +22,7 @@ description: Design System & Component rules for Blazor
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`). - Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
- **Typography:** - **Typography:**
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels. - UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
- Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability. - Reading Content: `Merriweather` (Serif) for books and articles to ensure high readability.
- **Effects:** - **Effects:**
- Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`). - Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`).
- Glassmorphism for overlays and modals. - Glassmorphism for overlays and modals.
@@ -30,11 +30,6 @@ description: Design System & Component rules for Blazor
- **Adaptive Layouts:** - **Adaptive Layouts:**
- Support `.platform-mobile` and `.platform-desktop` context classes. - Support `.platform-mobile` and `.platform-desktop` context classes.
- Handle safe-area insets (`--safe-area-inset-*`) for mobile devices. - Handle safe-area insets (`--safe-area-inset-*`) for mobile devices.
- **Immersive Reader (Zen Mode):**
- Centered content flow: `max-width: 800px`, `margin: 0 auto`.
- Paper-white background: `#F9F9F9` for light mode reader canvas.
- Dedicated Scrollbars: Custom styled, thin scrollbars with `--nexus-neon` accents.
- Reachability: Large `padding-bottom` (e.g., `15rem`) to ensure comfortable reading of end-of-page content.
- **Accessibility (A11y):** - **Accessibility (A11y):**
- Touch Targets: Min `44x44px` on mobile (enforced via CSS variables). - Touch Targets: Min `44x44px` on mobile (enforced via CSS variables).
-1
View File
@@ -6,7 +6,6 @@
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" /> <Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" /> <Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" />
<Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" /> <Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" />
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" /> <Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
</Folder> </Folder>
<Folder Name="/src/NexusReader.Web.New/"> <Folder Name="/src/NexusReader.Web.New/">
+44
View File
@@ -0,0 +1,44 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using NexusReader.Infrastructure.Persistence;
using Microsoft.Extensions.Configuration;
using NexusReader.Domain.Entities;
using System;
using System.Linq;
using System.Threading.Tasks;
var configuration = new ConfigurationBuilder()
.AddJsonFile("src/NexusReader.Web.New/appsettings.json")
.Build();
var services = new ServiceCollection();
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
if (!string.IsNullOrEmpty(pgConnectionString))
{
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(pgConnectionString));
}
else
{
services.AddDbContext<AppDbContext>(options => options.UseSqlite(configuration.GetConnectionString("SqliteConnection")));
}
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try
{
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == "admin@nexus.com");
if (user == null)
{
Console.WriteLine("User admin@nexus.com NOT FOUND in database.");
}
else
{
Console.WriteLine($"User found: {user.Email}, Id: {user.Id}, EmailConfirmed: {user.EmailConfirmed}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error accessing database: {ex.Message}");
}
+25
View File
@@ -0,0 +1,25 @@
using System;
using System.Text.RegularExpressions;
public class Program
{
public static void Main()
{
string input1 = "Hello \n World";
string input2 = "Hello World";
string norm1 = Normalize(input1);
string norm2 = Normalize(input2);
Console.WriteLine($"Input 1: '{input1}' -> Normalized: '{norm1}'");
Console.WriteLine($"Input 2: '{input2}' -> Normalized: '{norm2}'");
Console.WriteLine($"Match: {norm1 == norm2}");
}
public static string Normalize(string input)
{
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
var normalized = Regex.Replace(input.Trim(), @"\s+", " ");
return normalized.ToLowerInvariant();
}
}
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
public interface IApplicationDbContext
{
DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache { get; }
DbSet<KnowledgeUnit> KnowledgeUnits { get; }
DbSet<KnowledgeUnitLink> KnowledgeUnitLinks { get; }
DbSet<Ebook> Ebooks { get; }
DbSet<QuizResult> QuizResults { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -3,4 +3,4 @@ using MediatR;
namespace NexusReader.Application.Commands.Sync; namespace NexusReader.Application.Commands.Sync;
public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest<Result>; public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
@@ -5,6 +5,5 @@ public record SubscriptionPlanDto
public int Id { get; init; } public int Id { get; init; }
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
public int AITokenLimit { get; init; } public int AITokenLimit { get; init; }
public bool IsUnlimitedTokens { get; init; }
public decimal MonthlyPrice { get; init; } public decimal MonthlyPrice { get; init; }
} }
@@ -4,7 +4,6 @@ public record UserProfileDto
{ {
public string Email { get; init; } = string.Empty; public string Email { get; init; } = string.Empty;
public int AITokensUsed { get; init; } public int AITokensUsed { get; init; }
public Guid TenantId { get; init; }
/// <summary> /// <summary>
/// Relational data for the current subscription plan. /// Relational data for the current subscription plan.
@@ -8,7 +8,6 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services) public static IServiceCollection AddApplication(this IServiceCollection services)
{ {
services.AddMapsterConfiguration(); services.AddMapsterConfiguration();
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services; return services;
} }
@@ -2,7 +2,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" /> <ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
<ProjectReference Include="..\NexusReader.Data\NexusReader.Data.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -14,7 +13,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" /> <PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" /> <PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.1" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -4,8 +4,7 @@ using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using NexusReader.Application.DTOs.AI; using NexusReader.Application.DTOs.AI;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
using Pgvector; using Pgvector;
using Pgvector.EntityFrameworkCore; using Pgvector.EntityFrameworkCore;
using System.Text.Json; using System.Text.Json;
@@ -17,14 +16,14 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId,
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>> public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
{ {
private readonly IDbContextFactory<AppDbContext> _dbContextFactory; private readonly IApplicationDbContext _dbContext;
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator; private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
public SearchLibrarySemanticallyQueryHandler( public SearchLibrarySemanticallyQueryHandler(
IDbContextFactory<AppDbContext> dbContextFactory, IApplicationDbContext dbContext,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator) IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
{ {
_dbContextFactory = dbContextFactory; _dbContext = dbContext;
_embeddingGenerator = embeddingGenerator; _embeddingGenerator = embeddingGenerator;
} }
@@ -35,7 +34,6 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
return Result.Fail("Query text cannot be empty."); return Result.Fail("Query text cannot be empty.");
} }
using var dbContext = _dbContextFactory.CreateDbContext();
try try
{ {
// 1. Generate embedding for user query // 1. Generate embedding for user query
@@ -43,7 +41,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray()); var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
// 2. Perform Cosine Similarity Search on Knowledge Units // 2. Perform Cosine Similarity Search on Knowledge Units
var candidates = await dbContext.KnowledgeUnits var candidates = await _dbContext.KnowledgeUnits
.AsNoTracking() .AsNoTracking()
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) .Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
.OrderBy(x => x.Vector!.CosineDistance(queryVector)) .OrderBy(x => x.Vector!.CosineDistance(queryVector))
@@ -53,7 +51,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
if (!candidates.Any()) if (!candidates.Any())
{ {
// Fallback to legacy cache if no granular units found // Fallback to legacy cache if no granular units found
var legacyResults = await dbContext.SemanticKnowledgeCache var legacyResults = await _dbContext.SemanticKnowledgeCache
.AsNoTracking() .AsNoTracking()
.Where(x => x.TenantId == request.TenantId && x.Vector != null) .Where(x => x.TenantId == request.TenantId && x.Vector != null)
.OrderBy(x => x.Vector!.CosineDistance(queryVector)) .OrderBy(x => x.Vector!.CosineDistance(queryVector))
@@ -70,13 +68,13 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
// 3. Graph Expansion: Pull related units (e.g. Definitions, Next steps) // 3. Graph Expansion: Pull related units (e.g. Definitions, Next steps)
var candidateIds = candidates.Select(c => c.Id).ToList(); var candidateIds = candidates.Select(c => c.Id).ToList();
var links = await dbContext.KnowledgeUnitLinks var links = await _dbContext.KnowledgeUnitLinks
.AsNoTracking() .AsNoTracking()
.Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next")) .Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next"))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList(); var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList();
var relatedUnits = await dbContext.KnowledgeUnits var relatedUnits = await _dbContext.KnowledgeUnits
.AsNoTracking() .AsNoTracking()
.Where(u => relatedIds.Contains(u.Id)) .Where(u => relatedIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, cancellationToken); .ToDictionaryAsync(u => u.Id, cancellationToken);
@@ -2,19 +2,16 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Security.Authorization; namespace NexusReader.Application.Security.Authorization;
public class ProUserHandler : AuthorizationHandler<ProUserRequirement> public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
{ {
private readonly IDbContextFactory<AppDbContext> _dbContextFactory; private readonly UserManager<NexusUser> _userManager;
public ProUserHandler(IDbContextFactory<AppDbContext> dbContextFactory) public ProUserHandler(UserManager<NexusUser> userManager)
{ {
_dbContextFactory = dbContextFactory; _userManager = userManager;
} }
protected override async Task HandleRequirementAsync( protected override async Task HandleRequirementAsync(
@@ -27,18 +24,14 @@ public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
return; return;
} }
using var db = _dbContextFactory.CreateDbContext(); var user = await _userManager.FindByIdAsync(userId);
var user = await db.Users
.Include(u => u.SubscriptionPlan)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) if (user == null)
{ {
return; return;
} }
// Rule 1: Unlimited access // Rule 1: Explicit Pro plan
if (user.SubscriptionPlan?.IsUnlimitedTokens == true) if (user.SubscriptionPlanId == SubscriptionPlan.ProId)
{ {
context.Succeed(requirement); context.Succeed(requirement);
return; return;
@@ -1,659 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable
namespace NexusReader.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens")]
partial class UpdateSubscriptionPlanIsUnlimitedTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
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.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp with time zone");
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("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<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<Vector>("Vector")
.HasColumnType("vector(768)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("SourceId");
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<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.Property<Vector>("Vector")
.HasColumnType("vector(1536)");
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.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
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.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
b.Navigation("QuizResults");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,71 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Data.Migrations
{
/// <inheritdoc />
public partial class UpdateSubscriptionPlanIsUnlimitedTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsUnlimitedTokens",
table: "SubscriptionPlans",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "AITokenLimit", "IsUnlimitedTokens", "StripeProductId" },
values: new object[] { 5000, false, "prod_Free789" });
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: 2,
column: "IsUnlimitedTokens",
value: false);
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: 3,
column: "IsUnlimitedTokens",
value: false);
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: 4,
columns: new[] { "AITokenLimit", "IsUnlimitedTokens" },
values: new object[] { 1000000000, true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsUnlimitedTokens",
table: "SubscriptionPlans");
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "AITokenLimit", "StripeProductId" },
values: new object[] { 1000, "" });
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: 4,
column: "AITokenLimit",
value: 500000);
}
}
}
@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
</ItemGroup>
</Project>
@@ -23,8 +23,6 @@ public class SubscriptionPlan
public int AITokenLimit { get; set; } public int AITokenLimit { get; set; }
public bool IsUnlimitedTokens { get; set; }
public decimal MonthlyPrice { get; set; } public decimal MonthlyPrice { get; set; }
[MaxLength(50)] [MaxLength(50)]
@@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using GeminiDotnet; using GeminiDotnet;
using GeminiDotnet.Extensions.AI; using GeminiDotnet.Extensions.AI;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Services; using NexusReader.Infrastructure.Services;
using NexusReader.Infrastructure.Configuration; using NexusReader.Infrastructure.Configuration;
@@ -4,28 +4,27 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Commands.Sync; using NexusReader.Application.Commands.Sync;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using NexusReader.Infrastructure.RealTime; using NexusReader.Infrastructure.RealTime;
namespace NexusReader.Infrastructure.Handlers; namespace NexusReader.Infrastructure.Handlers;
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result> public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
{ {
private readonly IDbContextFactory<AppDbContext> _dbContextFactory; private readonly AppDbContext _context;
private readonly IHubContext<SyncHub> _hubContext; private readonly IHubContext<SyncHub> _hubContext;
public UpdateReadingProgressCommandHandler( public UpdateReadingProgressCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory, AppDbContext context,
IHubContext<SyncHub> hubContext) IHubContext<SyncHub> hubContext)
{ {
_dbContextFactory = dbContextFactory; _context = context;
_hubContext = hubContext; _hubContext = hubContext;
} }
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken) public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
{ {
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null) if (user == null)
{ {
return Result.Fail("User not found."); return Result.Fail("User not found.");
@@ -35,21 +34,12 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
user.LastReadPageId = request.PageId; user.LastReadPageId = request.PageId;
user.LastReadAt = now; user.LastReadAt = now;
await context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Broadcast to other devices // Broadcast to other devices
var group = _hubContext.Clients.Group($"User_{request.UserId}"); await _hubContext.Clients
.Group($"User_{request.UserId}")
if (!string.IsNullOrEmpty(request.ExcludedConnectionId)) .SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
{
await _hubContext.Clients
.GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
else
{
await group.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
return Result.Ok(); return Result.Ok();
} }
@@ -2,7 +2,7 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace NexusReader.Infrastructure.Identity; namespace NexusReader.Infrastructure.Identity;
@@ -31,18 +31,14 @@ public class TokenLimitHandler : AuthorizationHandler<TokenLimitRequirement>
return; return;
} }
using var db = _dbContextFactory.CreateDbContext(); var user = await _userManager.FindByIdAsync(userId);
var user = await db.Users
.Include(u => u.SubscriptionPlan)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) if (user == null)
{ {
return; return;
} }
// Check if user has available tokens or unlimited plan // Check if user has available tokens
if (user.SubscriptionPlan?.IsUnlimitedTokens == true || user.AITokensUsed < user.AITokenLimit) if (user.AITokensUsed < user.AITokenLimit)
{ {
context.Succeed(requirement); context.Succeed(requirement);
} }
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260428184727_InitialPostgres")] [Migration("20260428184727_InitialPostgres")]
@@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialPostgres : Migration public partial class InitialPostgres : Migration
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260428185239_IncreaseHashLength")] [Migration("20260428185239_IncreaseHashLength")]
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class IncreaseHashLength : Migration public partial class IncreaseHashLength : Migration
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260429080302_AddQuizResults")] [Migration("20260429080302_AddQuizResults")]
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddQuizResults : Migration public partial class AddQuizResults : Migration
@@ -4,13 +4,13 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector; using Pgvector;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")] [Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")]
@@ -7,7 +7,7 @@ using Pgvector;
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class FinalNormalizedSubscriptionArchitecture : Migration public partial class FinalNormalizedSubscriptionArchitecture : Migration
@@ -3,13 +3,13 @@ using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector; using Pgvector;
#nullable disable #nullable disable
namespace NexusReader.Data.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot partial class AppDbContextModelSnapshot : ModelSnapshot
@@ -200,7 +200,7 @@ namespace NexusReader.Data.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("Ebooks", (string)null); b.ToTable("Ebooks");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
@@ -246,7 +246,7 @@ namespace NexusReader.Data.Migrations
b.HasIndex("TenantId"); b.HasIndex("TenantId");
b.ToTable("KnowledgeUnits", (string)null); b.ToTable("KnowledgeUnits");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
@@ -278,7 +278,7 @@ namespace NexusReader.Data.Migrations
b.HasIndex("TargetUnitId"); b.HasIndex("TargetUnitId");
b.ToTable("KnowledgeUnitLinks", (string)null); b.ToTable("KnowledgeUnitLinks");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
@@ -413,7 +413,7 @@ namespace NexusReader.Data.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("QuizResults", (string)null); b.ToTable("QuizResults");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
@@ -458,7 +458,7 @@ namespace NexusReader.Data.Migrations
b.HasIndex("TenantId"); b.HasIndex("TenantId");
b.ToTable("SemanticKnowledgeCache", (string)null); b.ToTable("SemanticKnowledgeCache");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b => modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
@@ -472,9 +472,6 @@ namespace NexusReader.Data.Migrations
b.Property<int>("AITokenLimit") b.Property<int>("AITokenLimit")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<bool>("IsUnlimitedTokens")
.HasColumnType("boolean");
b.Property<decimal>("MonthlyPrice") b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric"); .HasColumnType("numeric");
@@ -493,23 +490,21 @@ namespace NexusReader.Data.Migrations
b.HasIndex("PlanName") b.HasIndex("PlanName")
.IsUnique(); .IsUnique();
b.ToTable("SubscriptionPlans", (string)null); b.ToTable("SubscriptionPlans");
b.HasData( b.HasData(
new new
{ {
Id = 1, Id = 1,
AITokenLimit = 5000, AITokenLimit = 1000,
IsUnlimitedTokens = false,
MonthlyPrice = 0m, MonthlyPrice = 0m,
PlanName = "Free", PlanName = "Free",
StripeProductId = "prod_Free789" StripeProductId = ""
}, },
new new
{ {
Id = 2, Id = 2,
AITokenLimit = 10000, AITokenLimit = 10000,
IsUnlimitedTokens = false,
MonthlyPrice = 9.99m, MonthlyPrice = 9.99m,
PlanName = "Basic", PlanName = "Basic",
StripeProductId = "prod_basic_placeholder" StripeProductId = "prod_basic_placeholder"
@@ -518,7 +513,6 @@ namespace NexusReader.Data.Migrations
{ {
Id = 3, Id = 3,
AITokenLimit = 50000, AITokenLimit = 50000,
IsUnlimitedTokens = false,
MonthlyPrice = 19.99m, MonthlyPrice = 19.99m,
PlanName = "Pro", PlanName = "Pro",
StripeProductId = "prod_pro_placeholder" StripeProductId = "prod_pro_placeholder"
@@ -526,8 +520,7 @@ namespace NexusReader.Data.Migrations
new new
{ {
Id = 4, Id = 4,
AITokenLimit = 1000000000, AITokenLimit = 500000,
IsUnlimitedTokens = true,
MonthlyPrice = 99.99m, MonthlyPrice = 99.99m,
PlanName = "Enterprise", PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder" StripeProductId = "prod_enterprise_placeholder"
@@ -2,7 +2,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" /> <ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\NexusReader.Data\NexusReader.Data.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,23 +1,16 @@
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.Application.Abstractions.Persistence;
namespace NexusReader.Infrastructure.Persistence;
namespace NexusReader.Data.Persistence; public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
public class AppDbContext : IdentityDbContext<NexusUser>
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{ {
} }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
// Suppress the pending model changes warning to avoid runtime exceptions in some environments
optionsBuilder.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
}
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>(); public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>();
public DbSet<KnowledgeUnit> KnowledgeUnits => Set<KnowledgeUnit>(); public DbSet<KnowledgeUnit> KnowledgeUnits => Set<KnowledgeUnit>();
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>(); public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
@@ -27,8 +20,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder);
modelBuilder.HasPostgresExtension("vector"); modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<NexusUser>(entity => modelBuilder.Entity<NexusUser>(entity =>
@@ -47,6 +38,8 @@ public class AppDbContext : IdentityDbContext<NexusUser>
.HasDefaultValue(1); .HasDefaultValue(1);
}); });
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SubscriptionPlan>(entity => modelBuilder.Entity<SubscriptionPlan>(entity =>
{ {
entity.HasIndex(p => p.PlanName).IsUnique(); entity.HasIndex(p => p.PlanName).IsUnique();
@@ -104,10 +97,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>
// 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 = 1000, MonthlyPrice = 0m, StripeProductId = "" },
new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, IsUnlimitedTokens = false, MonthlyPrice = 9.99m, StripeProductId = "prod_basic_placeholder" }, new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, MonthlyPrice = 9.99m, StripeProductId = "prod_basic_placeholder" },
new SubscriptionPlan { Id = 3, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, IsUnlimitedTokens = false, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" }, new SubscriptionPlan { Id = 3, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" },
new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" } new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" }
); );
} }
} }
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Pgvector.EntityFrameworkCore; using Pgvector.EntityFrameworkCore;
namespace NexusReader.Data.Persistence; namespace NexusReader.Infrastructure.Persistence;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext> public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{ {
@@ -7,16 +7,16 @@ using System.Threading.Tasks;
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace NexusReader.Data.Persistence; namespace NexusReader.Infrastructure.Persistence;
public static class DbInitializer public static class DbInitializer
{ {
public static async Task SeedAsync(IServiceProvider serviceProvider) public static async Task SeedAsync(IServiceProvider serviceProvider)
{ {
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>(); var userManager = scope.ServiceProvider.GetRequiredService<UserManager<NexusUser>>();
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>(); var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
using var dbContext = await dbContextFactory.CreateDbContextAsync(); var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try try
{ {
@@ -27,9 +27,9 @@ public static class DbInitializer
{ {
dbContext.SubscriptionPlans.AddRange(new List<SubscriptionPlan> dbContext.SubscriptionPlans.AddRange(new List<SubscriptionPlan>
{ {
new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0, StripeProductId = "prod_Free789" }, new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, MonthlyPrice = 0, StripeProductId = "prod_Free789" },
new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, IsUnlimitedTokens = false, MonthlyPrice = 19, StripeProductId = "prod_Pro123" }, new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19, StripeProductId = "prod_Pro123" },
new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" } new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" }
}); });
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
Console.WriteLine("[Seeder] Subscription plans seeded."); Console.WriteLine("[Seeder] Subscription plans seeded.");
@@ -39,45 +39,41 @@ public static class DbInitializer
string[] roleNames = { "Admin", "User" }; string[] roleNames = { "Admin", "User" };
foreach (var roleName in roleNames) foreach (var roleName in roleNames)
{ {
var roleExist = dbContext.Roles.Any(r => r.Name == roleName); var roleExist = await roleManager.RoleExistsAsync(roleName);
if (!roleExist) if (!roleExist)
{ {
dbContext.Roles.Add(new IdentityRole { Name = roleName, NormalizedName = roleName.ToUpper() }); await roleManager.CreateAsync(new IdentityRole(roleName));
Console.WriteLine($"[Seeder] Created role: {roleName}"); Console.WriteLine($"[Seeder] Created role: {roleName}");
} }
} }
await dbContext.SaveChangesAsync();
// Seed Admin User // Seed Admin User
var adminEmail = "admin@nexus.com"; var adminEmail = "admin@nexus.com";
var normalizedEmail = adminEmail.ToUpper(); var adminUser = await userManager.FindByEmailAsync(adminEmail);
var adminUser = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
if (adminUser == null) if (adminUser == null)
{ {
adminUser = new NexusUser adminUser = new NexusUser
{ {
UserName = adminEmail, UserName = adminEmail,
NormalizedUserName = normalizedEmail,
Email = adminEmail, Email = adminEmail,
NormalizedEmail = normalizedEmail,
EmailConfirmed = true, EmailConfirmed = true,
SubscriptionPlanId = SubscriptionPlan.EnterpriseId, SubscriptionPlanId = SubscriptionPlan.EnterpriseId,
AITokenLimit = 1000000, AITokenLimit = 1000000,
TenantId = Guid.NewGuid().ToString(), TenantId = Guid.NewGuid().ToString()
SecurityStamp = Guid.NewGuid().ToString()
}; };
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!"); var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!");
if (createPowerUser.Succeeded)
dbContext.Users.Add(adminUser); {
await dbContext.SaveChangesAsync(); await userManager.AddToRoleAsync(adminUser, "Admin");
Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}");
var adminRole = await dbContext.Roles.FirstAsync(r => r.Name == "Admin"); }
dbContext.UserRoles.Add(new IdentityUserRole<string> { UserId = adminUser.Id, RoleId = adminRole.Id }); else
await dbContext.SaveChangesAsync(); {
var errors = string.Join(", ", createPowerUser.Errors.Select(e => e.Description));
Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}"); Console.WriteLine($"[Seeder] Failed to create admin user: {errors}");
}
} }
else else
{ {
@@ -20,7 +20,7 @@ public class SyncHub : Hub
var userId = Context.UserIdentifier; var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
{ {
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId)); await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId));
} }
} }
@@ -5,24 +5,24 @@ using Microsoft.Extensions.Options;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Configuration; using NexusReader.Infrastructure.Configuration;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
namespace NexusReader.Infrastructure.Services; namespace NexusReader.Infrastructure.Services;
public class BillingService : IBillingService public class BillingService : IBillingService
{ {
private readonly IDbContextFactory<AppDbContext> _dbContextFactory; private readonly AppDbContext _dbContext;
private readonly UserManager<NexusUser> _userManager; private readonly UserManager<NexusUser> _userManager;
private readonly StripeSettings _stripeSettings; private readonly StripeSettings _stripeSettings;
private readonly ILogger<BillingService> _logger; private readonly ILogger<BillingService> _logger;
public BillingService( public BillingService(
IDbContextFactory<AppDbContext> dbContextFactory, AppDbContext dbContext,
UserManager<NexusUser> userManager, UserManager<NexusUser> userManager,
IOptions<StripeSettings> stripeSettings, IOptions<StripeSettings> stripeSettings,
ILogger<BillingService> logger) ILogger<BillingService> logger)
{ {
_dbContextFactory = dbContextFactory; _dbContext = dbContext;
_userManager = userManager; _userManager = userManager;
_stripeSettings = stripeSettings.Value; _stripeSettings = stripeSettings.Value;
_logger = logger; _logger = logger;
@@ -55,8 +55,7 @@ public class BillingService : IBillingService
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail); _logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
} }
using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
if (plan != null) if (plan != null)
{ {
user.SubscriptionPlanId = plan.Id; user.SubscriptionPlanId = plan.Id;
@@ -83,8 +82,7 @@ public class BillingService : IBillingService
return false; return false;
} }
using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
if (freePlan != null) if (freePlan != null)
{ {
user.SubscriptionPlanId = freePlan.Id; user.SubscriptionPlanId = freePlan.Id;
@@ -46,35 +46,34 @@ public class EpubService : IEpubService
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist."); return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
} }
using var bookRef = await EpubReader.OpenBookAsync(fullPath); EpubBook book;
var readingOrder = bookRef.GetReadingOrder(); try
{
book = await EpubReader.ReadBookAsync(fullPath);
}
catch (Exception ex)
{
return Result.Fail(new Error($"Failed to parse EPUB file. It might be corrupted or in use. Path: {fullPath}").CausedBy(ex));
}
var blocks = new List<ContentBlock>();
int totalWordCount = 0;
int blockCounter = 0;
if (readingOrder == null || !readingOrder.Any()) if (book.ReadingOrder == null || !book.ReadingOrder.Any())
{ {
return Result.Fail("The EPUB has no readable content files in ReadingOrder."); return Result.Fail("The EPUB has no readable content files in ReadingOrder.");
} }
// Ensure index is within bounds // Ensure index is within bounds
if (chapterIndex < 0 || chapterIndex >= readingOrder.Count) if (chapterIndex < 0 || chapterIndex >= book.ReadingOrder.Count)
{ {
chapterIndex = 0; // Default to first chapter chapterIndex = 0; // Default to first chapter
} }
var chapterRef = readingOrder[chapterIndex]; var chapter = book.ReadingOrder[chapterIndex];
var chapterTitle = chapter.FilePath ?? $"Chapter {chapterIndex + 1}";
// Try to find a better title from navigation (TOC) var paragraphs = ExtractParagraphs(chapter.Content);
var navigation = bookRef.GetNavigation();
var chapterTitle = FindTitleInNavigation(navigation, chapterRef.FilePath)
?? Path.GetFileNameWithoutExtension(chapterRef.FilePath)
?? $"Chapter {chapterIndex + 1}";
var chapterContent = await chapterRef.ReadContentAsTextAsync();
var blocks = new List<ContentBlock>();
int totalWordCount = 0;
int blockCounter = 0;
var paragraphs = ExtractParagraphs(chapterContent);
foreach (var p in paragraphs) foreach (var p in paragraphs)
{ {
var sanitizedContent = SanitizeParagraph(p); var sanitizedContent = SanitizeParagraph(p);
@@ -100,7 +99,7 @@ public class EpubService : IEpubService
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}")); blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
} }
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle)); return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, book.ReadingOrder.Count, chapterTitle));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -163,25 +162,4 @@ public class EpubService : IEpubService
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" } new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" }
); );
} }
private string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
{
if (string.IsNullOrEmpty(filePath)) return null;
var fileName = Path.GetFileName(filePath);
foreach (var item in navigation)
{
// Match by full path or just filename as fallback
if (item.Link?.ContentFilePath == filePath || item.Link?.ContentFilePath == fileName)
return item.Title;
if (item.NestedItems != null && item.NestedItems.Any())
{
var childTitle = FindTitleInNavigation(item.NestedItems, filePath);
if (childTitle != null) return childTitle;
}
}
return null;
}
} }
@@ -7,7 +7,7 @@ using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI; using NexusReader.Application.DTOs.AI;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Helpers; using NexusReader.Infrastructure.Helpers;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Polly; using Polly;
using Polly.Registry; using Polly.Registry;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -4,8 +4,6 @@ public static class PromptRegistry
{ {
public const string KnowledgeExtractionSystemPrompt = public const string KnowledgeExtractionSystemPrompt =
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " + "You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " +
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " + "CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
"Schema: { " + "Schema: { " +
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " + "\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
@@ -15,15 +13,11 @@ public static class PromptRegistry
public const string GraphExtractionPrompt = public const string GraphExtractionPrompt =
"You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " + "You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " +
"CRITICAL: Restrict 'label' to a maximum of 3 words. " + "CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " +
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points and their relationships. " +
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. Use these IDs ONLY for nodes representing the blocks. " +
"CRITICAL: All other extracted 'concept' nodes MUST have unique, slug-style IDs based on their labels (e.g., 'dependency-injection'). " +
"Include a 'current' node representing the block content itself if applicable. " + "Include a 'current' node representing the block content itself if applicable. " +
"CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " + "CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " +
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
public const string SummaryAndQuizPrompt = public const string SummaryAndQuizPrompt =
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " + "You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }"; "Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
+2 -2
View File
@@ -3,7 +3,7 @@
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly"> <Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainHubLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)">
<NotAuthorized> <NotAuthorized>
<RedirectToLogin /> <RedirectToLogin />
</NotAuthorized> </NotAuthorized>
@@ -11,7 +11,7 @@
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found> </Found>
<NotFound> <NotFound>
<LayoutView Layout="@typeof(NexusReader.UI.Shared.Layout.MainHubLayout)"> <LayoutView Layout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p> <p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView> </LayoutView>
</NotFound> </NotFound>
@@ -1,27 +1,6 @@
<svg class="nexus-icon @Class" viewBox="0 0 24 24" fill="currentColor" width="@Size" height="@Size" @attributes="AdditionalAttributes"> <svg class="nexus-icon @Class" viewBox="0 0 24 24" fill="currentColor" width="@Size" height="@Size" @attributes="AdditionalAttributes">
@switch (Name.ToLowerInvariant()) @switch (Name.ToLowerInvariant())
{ {
case "home":
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "map":
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "share-2":
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="18" cy="19" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "help-circle":
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="12" y1="17" x2="12.01" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "robot": case "robot":
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" /> <path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
break; break;
@@ -37,24 +16,8 @@
case "message-square": case "message-square":
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
break; break;
case "diamond":
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "layout":
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "book-open":
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "user":
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "settings": case "settings":
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" />
break; break;
case "bookmark": case "bookmark":
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
@@ -77,15 +40,6 @@
case "eye-off": case "eye-off":
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" /> <path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
break; break;
case "arrow-left":
<path d="M19 12H5M12 19l-7-7 7-7" />
break;
case "arrow-right":
<path d="M5 12h14M12 5l7 7-7 7" />
break;
case "log-out":
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
default: default:
<!-- Fallback circle --> <!-- Fallback circle -->
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -42,7 +42,6 @@
/// <summary>Fallback static dialogue shown when no live AI content is available.</summary> /// <summary>Fallback static dialogue shown when no live AI content is available.</summary>
[Parameter] public string Dialogue { get; set; } = string.Empty; [Parameter] public string Dialogue { get; set; } = string.Empty;
[Parameter] public List<string> Actions { get; set; } = new(); [Parameter] public List<string> Actions { get; set; } = new();
[Parameter] public string FullPageContent { get; set; } = string.Empty;
[Parameter] public EventCallback<string> OnActionTriggered { get; set; } [Parameter] public EventCallback<string> OnActionTriggered { get; set; }
private string _displayedText = string.Empty; private string _displayedText = string.Empty;
@@ -77,11 +76,8 @@
try try
{ {
var contentToAnalyze = !string.IsNullOrWhiteSpace(FullPageContent) _packet = await Coordinator.RequestSummaryAndQuizAsync(
? FullPageContent $"[ID: {ContextBlockId}]\n{Dialogue}");
: $"[ID: {ContextBlockId}]\n{Dialogue}";
_packet = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze);
var summary = _packet?.Summary; var summary = _packet?.Summary;
@@ -129,7 +125,7 @@
{ {
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase)) if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
{ {
await QuizState.RequestQuiz(ContextBlockId); QuizState.RequestQuiz(ContextBlockId);
} }
if (OnActionTriggered.HasDelegate) if (OnActionTriggered.HasDelegate)
@@ -18,7 +18,39 @@
} }
</div> </div>
<style>
.groundedness-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.groundedness-badge.status-high {
color: var(--nexus-neon);
border-color: var(--nexus-neon);
}
.groundedness-badge.status-medium {
color: #ffaa00;
border-color: #ffaa00;
}
.groundedness-badge.status-low {
color: #ff4444;
border-color: #ff4444;
}
.shimmer {
opacity: 0.6;
}
</style>
@code { @code {
[Parameter] public string Answer { get; set; } = string.Empty; [Parameter] public string Answer { get; set; } = string.Empty;
@@ -1,49 +0,0 @@
.groundedness-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.groundedness-badge.status-high {
color: var(--nexus-neon);
border-color: var(--nexus-neon);
}
.groundedness-badge.status-medium {
color: #ffaa00;
border-color: #ffaa00;
}
.groundedness-badge.status-low {
color: #ff4444;
border-color: #ff4444;
}
.shimmer {
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 70%
);
background-size: 200% 100%;
animation: shimmer-move 2s infinite linear;
display: inline-block;
color: rgba(255, 255, 255, 0.6);
}
@keyframes shimmer-move {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@@ -2,13 +2,11 @@
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@inject IFocusModeService FocusMode @inject IFocusModeService FocusMode
@inject IKnowledgeService KnowledgeService @inject IKnowledgeService KnowledgeService
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<aside class="intelligence-toolbar"> <aside class="intelligence-toolbar">
<div class="toolbar-top"> <div class="toolbar-top">
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Back to Dashboard"> <button class="toolbar-item" title="Back">
<NexusIcon Name="arrow-left" Size="20" /> <NexusIcon Name="play" Size="20" Class="rotate-180" />
</button> </button>
<button class="toolbar-item active" title="Chat"> <button class="toolbar-item active" title="Chat">
<NexusIcon Name="message-square" Size="20" /> <NexusIcon Name="message-square" Size="20" />
@@ -35,11 +33,8 @@
@onclick="FocusMode.ToggleAsync" title="Focus Mode (F)"> @onclick="FocusMode.ToggleAsync" title="Focus Mode (F)">
<NexusIcon Name="target" Size="20" /> <NexusIcon Name="target" Size="20" />
</button> </button>
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Global Hub"> <button class="toolbar-item" title="Global Settings">
<NexusIcon Name="layers" Size="20" /> <NexusIcon Name="settings" Size="20" />
</button>
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
<NexusIcon Name="log-out" Size="20" />
</button> </button>
</div> </div>
</aside> </aside>
@@ -47,7 +42,7 @@
@code { @code {
protected override void OnInitialized() protected override void OnInitialized()
{ {
FocusMode.OnFocusModeChanged += HandleUpdate; FocusMode.OnFocusModeChanged += StateHasChanged;
} }
private async Task HandleClearCache() private async Task HandleClearCache()
@@ -61,16 +56,8 @@
} }
} }
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/", true);
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
FocusMode.OnFocusModeChanged -= HandleUpdate; FocusMode.OnFocusModeChanged -= StateHasChanged;
} }
} }
@@ -1,8 +1,8 @@
.intelligence-toolbar { .intelligence-toolbar {
width: 50px; width: 50px;
height: 100%; height: 100%;
background: #0D0D0D; background: #080808;
border-right: 1px solid rgba(255, 255, 255, 0.08); border-right: 1px solid rgba(255, 255, 255, 0.03);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -10,7 +10,6 @@
align-items: center; align-items: center;
z-index: 20; z-index: 20;
box-shadow: inset -2px 0 10px rgba(0,0,0,0.5); box-shadow: inset -2px 0 10px rgba(0,0,0,0.5);
backdrop-filter: blur(10px);
} }
@@ -23,7 +22,7 @@
.toolbar-item { .toolbar-item {
background: none; background: none;
border: none; border: none;
color: #555; color: #444;
cursor: pointer; cursor: pointer;
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -38,15 +37,11 @@
.toolbar-item:hover { .toolbar-item:hover {
color: var(--nexus-neon); color: var(--nexus-neon);
background: rgba(0, 255, 153, 0.05); background: rgba(0, 255, 153, 0.05);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
filter: drop-shadow(0 0 5px var(--nexus-neon));
} }
.toolbar-item.active { .toolbar-item.active {
color: var(--nexus-neon); color: var(--nexus-neon);
background: rgba(0, 255, 153, 0.08); background: rgba(0, 255, 153, 0.08);
box-shadow: 0 0 20px rgba(0, 255, 153, 0.25);
filter: drop-shadow(0 0 8px var(--nexus-neon));
} }
.toolbar-item.active::after { .toolbar-item.active::after {
@@ -75,22 +70,3 @@
color: #ff4d4d; color: #ff4d4d;
background: rgba(255, 77, 77, 0.1); background: rgba(255, 77, 77, 0.1);
} }
.toolbar-item.logout-item {
margin-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 1.5rem;
height: auto;
width: 100%;
display: flex;
justify-content: center;
border-radius: 0;
color: #444;
}
.toolbar-item.logout-item:hover {
color: #ff4d4d;
background: none;
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.4));
}
@@ -55,14 +55,12 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
QuizService.OnQuizUpdated += HandleUpdate; QuizService.OnQuizUpdated += () => InvokeAsync(StateHasChanged);
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
QuizService.OnQuizUpdated -= HandleUpdate; QuizService.OnQuizUpdated -= StateHasChanged;
} }
private async Task SelectOptionAsync(QuizQuestionDto question, int index) private async Task SelectOptionAsync(QuizQuestionDto question, int index)
@@ -29,7 +29,7 @@
</div> </div>
<div class="ai-actions"> <div class="ai-actions">
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button> <button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
<button class="action-btn ghost" @onclick="CloseAsync">Zamknij</button> <button class="action-btn ghost" @onclick="Close">Zamknij</button>
</div> </div>
} }
else else
@@ -39,7 +39,7 @@
</div> </div>
<div class="ai-actions"> <div class="ai-actions">
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button> <button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
<button class="action-btn ghost" @onclick="CloseAsync">Pomiń</button> <button class="action-btn ghost" @onclick="Close">Pomiń</button>
</div> </div>
} }
</div> </div>
@@ -76,11 +76,7 @@
private async Task RequestSummary() private async Task RequestSummary()
{ {
IsLoading = true; IsLoading = true;
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent) Packet = await Coordinator.RequestSummaryAndQuizAsync(SelectedText);
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: "";
Packet = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
IsLoading = false; IsLoading = false;
} }
@@ -89,12 +85,12 @@
IsLoading = true; IsLoading = true;
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent); await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
IsLoading = false; IsLoading = false;
await CloseAsync(); Close();
} }
private async Task CloseAsync() private void Close()
{ {
Packet = null; Packet = null;
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!); InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
} }
} }
@@ -46,7 +46,7 @@
GraphService.OnLoadingChanged += HandleLoadingChange; GraphService.OnLoadingChanged += HandleLoadingChange;
} }
private async Task HandleGraphUpdate() private async void HandleGraphUpdate()
{ {
if (_module == null) return; if (_module == null) return;
@@ -62,13 +62,13 @@
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private async Task HandleActiveNodeChange(string nodeId) private async void HandleActiveNodeChange(string nodeId)
{ {
if (_module == null) return; if (_module == null) return;
await _module.InvokeVoidAsync("setActiveNode", nodeId); await _module.InvokeVoidAsync("setActiveNode", nodeId);
} }
private async Task HandleLoadingChange(bool isLoading) private async void HandleLoadingChange(bool isLoading)
{ {
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -81,7 +81,7 @@
if (GraphService.CurrentGraphData != null) if (GraphService.CurrentGraphData != null)
{ {
await HandleGraphUpdate(); HandleGraphUpdate();
} }
} }
} }
@@ -100,7 +100,7 @@
[JSInvokable] [JSInvokable]
public async Task OnNodeClicked(string nodeId) public async Task OnNodeClicked(string nodeId)
{ {
await InteractionService.NotifyNodeSelected(nodeId); InteractionService.NotifyNodeSelected(nodeId);
if (OnNodeSelected.HasDelegate) if (OnNodeSelected.HasDelegate)
{ {
@@ -109,7 +109,7 @@
} }
private async Task HandleFocusSimulation() private async void HandleFocusSimulation()
{ {
if (_module == null) return; if (_module == null) return;
try try
@@ -12,33 +12,28 @@
.graph-controls { .graph-controls {
position: absolute; position: absolute;
bottom: 1.5rem; bottom: 1rem;
right: 1.5rem; right: 1.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
gap: 0.25rem; gap: 0.5rem;
background: rgba(20, 20, 20, 0.4);
backdrop-filter: blur(12px);
padding: 0.35rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10; z-index: 10;
} }
.zoom-btn { .zoom-btn {
width: 32px; width: 28px;
height: 32px; height: 28px;
background: rgba(255, 255, 255, 0.03); background: rgba(18, 18, 18, 0.8);
border: 1px solid rgba(255, 255, 255, 0.05); backdrop-filter: blur(4px);
border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.1);
color: #aaa; border-radius: 4px;
font-size: 1.1rem; color: #888;
font-size: 1rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s ease;
} }
.zoom-btn:hover { .zoom-btn:hover {
@@ -103,13 +98,3 @@
filter: drop-shadow(0 0 12px var(--nexus-neon)); filter: drop-shadow(0 0 12px var(--nexus-neon));
transition: all 0.3s ease; transition: all 0.3s ease;
} }
::deep @keyframes neon-flash {
0% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
50% { filter: brightness(3) drop-shadow(0 0 30px var(--nexus-neon)); }
100% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
}
::deep .neon-flash-node {
animation: neon-flash 0.8s ease-out;
}
@@ -52,16 +52,15 @@
private bool _isJsInitialized; private bool _isJsInitialized;
private ElementReference _containerRef; private ElementReference _containerRef;
protected override async Task OnInitializedAsync() protected override void OnInitialized()
{ {
await Coordinator.ClearAsync(); Coordinator.Clear();
ThemeService.OnThemeChanged += HandleUpdate; ThemeService.OnThemeChanged += StateHasChanged;
NavigationService.OnNavigationChanged += OnNavigationChanged; NavigationService.OnNavigationChanged += OnNavigationChanged;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested; InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested; InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected; InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived;
} }
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
@@ -114,56 +113,60 @@
} }
[JSInvokable] [JSInvokable]
public async Task HandleBlockReached(string blockId, string content) public void HandleBlockReached(string blockId, string content)
{ {
await Coordinator.OnBlockReachedAsync(blockId, content); Coordinator.OnBlockReached(blockId, content);
// Debounce sync update (simple version: every 5 seconds or on a timer) // Debounce sync update (simple version: every 5 seconds or on a timer)
await SyncService.UpdateProgressAsync(blockId); _ = SyncService.UpdateProgressAsync(blockId);
} }
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp) private void HandleSyncProgressReceived(string blockId, DateTime timestamp)
{ {
// For now, let's just scroll to the node if it's in the current view, // For now, let's just scroll to the node if it's in the current view,
// or just log it. Usually, we should prompt the user. // or just log it. Usually, we should prompt the user.
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}"); Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
await ScrollToNodeAsync(blockId); // Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
await InvokeAsync(StateHasChanged); // but we can assume incoming syncs are from other active devices)
_ = InvokeAsync(async () => {
await ScrollToNodeAsync(blockId);
StateHasChanged();
});
} }
[JSInvokable] [JSInvokable]
public async Task HandleTextSelected(string text, string blockId, SelectionCoordinates coords) public void HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
{ {
Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}"); Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}");
_selectedText = text; _selectedText = text;
_selectedBlockId = blockId; _selectedBlockId = blockId;
_selectionCoords = coords; _selectionCoords = coords;
await InvokeAsync(StateHasChanged); StateHasChanged();
} }
[JSInvokable] [JSInvokable]
public async Task HandleSelectionCleared() public void HandleSelectionCleared()
{ {
_selectedText = string.Empty; _selectedText = string.Empty;
_selectionCoords = null; _selectionCoords = null;
await InvokeAsync(StateHasChanged); StateHasChanged();
} }
private async Task HandleScrollRequested(string blockId) private void HandleScrollRequested(string blockId)
{ {
await ScrollToNodeAsync(blockId); _ = ScrollToNodeAsync(blockId);
} }
private async Task HandleHighlightRequested(string blockId) private async void HandleHighlightRequested(string blockId)
{ {
_highlightedBlockId = blockId; _highlightedBlockId = blockId;
await InvokeAsync(StateHasChanged); StateHasChanged();
await Task.Delay(3000); // Highlight for 3 seconds await Task.Delay(3000); // Highlight for 3 seconds
if (_highlightedBlockId == blockId) if (_highlightedBlockId == blockId)
{ {
_highlightedBlockId = null; _highlightedBlockId = null;
await InvokeAsync(StateHasChanged); StateHasChanged();
} }
} }
@@ -209,11 +212,9 @@
catch { } catch { }
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
ThemeService.OnThemeChanged -= HandleUpdate; ThemeService.OnThemeChanged -= StateHasChanged;
NavigationService.OnNavigationChanged -= OnNavigationChanged; NavigationService.OnNavigationChanged -= OnNavigationChanged;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested; InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
@@ -1,47 +1,16 @@
.reader-canvas { .reader-canvas {
width: 100%; max-width: 800px;
height: 100%; margin: 0 auto;
overflow-y: auto; padding: 2rem 1rem;
overflow-x: hidden;
padding: 2rem 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
/* Dedicated Scrollbar Styling */
scrollbar-width: thin;
scrollbar-color: rgba(0, 255, 153, 0.2) transparent;
}
.reader-canvas::-webkit-scrollbar {
width: 6px;
}
.reader-canvas::-webkit-scrollbar-track {
background: transparent;
}
.reader-canvas::-webkit-scrollbar-thumb {
background-color: rgba(0, 255, 153, 0.2);
border-radius: 20px;
border: 3px solid transparent;
}
.reader-canvas:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 255, 153, 0.5);
}
.reader-canvas.theme-light {
background-color: #F9F9F9; /* Paper-white requirement */
} }
.reader-flow-container { .reader-flow-container {
max-width: 800px;
margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
position: relative; position: relative;
padding: 0 1.5rem 15rem 1.5rem; /* Large padding-bottom for reachability */
} }
.block-wrapper { .block-wrapper {
@@ -51,68 +20,6 @@
border: 1px solid transparent; border: 1px solid transparent;
} }
/* Typographic refinement for TextSegmentBlock */
::deep .nexus-ebook {
font-family: 'Merriweather', serif !important;
line-height: 1.65 !important;
letter-spacing: -0.01em !important;
font-size: 1.15rem;
font-weight: 300;
}
.theme-light ::deep .nexus-ebook {
color: #1a1a1a;
}
/* Technical Code Block Container */
::deep .nexus-ebook pre {
background-color: #2d2d2d; /* Dark theme for code for better contrast */
color: #e0e0e0;
padding: 1.25rem;
border-radius: 8px;
margin: 2rem 0;
overflow-x: auto;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
border-left: 4px solid var(--nexus-neon); /* Nexus neon accent */
/* Dedicated Scrollbar for Code */
scrollbar-width: thin;
scrollbar-color: rgba(0, 255, 153, 0.3) transparent;
}
::deep .nexus-ebook pre::-webkit-scrollbar {
height: 4px;
}
::deep .nexus-ebook pre::-webkit-scrollbar-thumb {
background: rgba(0, 255, 153, 0.3);
border-radius: 10px;
}
/* Monospace Typography Contrast */
::deep .nexus-ebook code {
font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace !important;
font-variant-ligatures: contextual;
line-height: 1.5;
tab-size: 4;
font-size: 0.9rem;
}
/* Inline Code Highlight */
::deep .nexus-ebook p code {
background-color: rgba(0, 0, 0, 0.05);
color: #d63384; /* Classic differentiator for inline code */
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
border: none;
}
.theme-dark ::deep .nexus-ebook p code {
background-color: rgba(255, 255, 255, 0.1);
color: #ff79c6;
}
.block-wrapper.highlighted { .block-wrapper.highlighted {
background: rgba(0, 243, 255, 0.08); background: rgba(0, 243, 255, 0.08);
box-shadow: 0 0 20px rgba(0, 243, 255, 0.15); box-shadow: 0 0 20px rgba(0, 243, 255, 0.15);
@@ -36,9 +36,10 @@
NavigationService.OnNavigationChanged += HandleNavigationChanged; NavigationService.OnNavigationChanged += HandleNavigationChanged;
} }
private async Task HandleNavigationChanged() private Task HandleNavigationChanged()
{ {
await InvokeAsync(StateHasChanged); StateHasChanged();
return Task.CompletedTask;
} }
private int CalculateProgress() private int CalculateProgress()
@@ -18,11 +18,9 @@
} }
.navigation-controls { .navigation-controls {
display: grid; display: flex;
grid-template-columns: 32px 1fr 32px;
align-items: center; align-items: center;
gap: 0.75rem; gap: 1rem;
width: 260px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -53,23 +51,18 @@
.chapter-info { .chapter-info {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 0.5rem;
min-width: 0; font-size: 0.85rem;
overflow: hidden;
color: #333; color: #333;
white-space: nowrap;
} }
.chapter-title { .chapter-title {
font-weight: 600; font-weight: 600;
font-size: 0.75rem; max-width: 180px;
max-width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
line-height: 1.2;
} }
.chapter-count { .chapter-count {
@@ -1,106 +0,0 @@
@inherits LayoutComponentBase
@using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Services
<div class="hub-container">
<AuthorizeView>
<Authorized>
<aside class="hub-sidebar">
<div class="sidebar-header">
<div class="logo">
<NexusIcon Name="diamond" Size="24" Class="logo-icon" />
<span class="logo-text">Nexus</span>
</div>
</div>
<nav class="sidebar-nav">
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All">
<div class="nav-icon">
<NexusIcon Name="home" Size="18" />
</div>
<span class="nav-text">Dashboard</span>
</NavLink>
<NavLink class="nav-item" href="/library">
<div class="nav-icon">
<NexusIcon Name="book-open" Size="18" />
</div>
<span class="nav-text">Library</span>
</NavLink>
<NavLink class="nav-item" href="/concepts-map">
<div class="nav-icon">
<NexusIcon Name="map" Size="18" />
</div>
<span class="nav-text">Concepts Map</span>
</NavLink>
<NavLink class="nav-item" href="/profile">
<div class="nav-icon">
<NexusIcon Name="message-square" Size="18" />
</div>
<span class="nav-text">Profile</span>
</NavLink>
<NavLink class="nav-item" href="/settings">
<div class="nav-icon">
<NexusIcon Name="settings" Size="18" />
</div>
<span class="nav-text">Settings</span>
</NavLink>
<NavLink class="nav-item" href="/concenters">
<div class="nav-icon">
<NexusIcon Name="target" Size="18" />
</div>
<span class="nav-text">Concenters</span>
</NavLink>
</nav>
<div class="sidebar-footer">
<div class="user-brief">
<div class="user-avatar">
@context.User.Identity?.Name?[0].ToString().ToUpper()
</div>
<div class="user-details">
<span class="user-name">@context.User.Identity?.Name</span>
</div>
</div>
<button class="logout-btn" @onclick="HandleLogout" title="Logout">
<NexusIcon Name="log-out" Size="18" />
</button>
</div>
</aside>
</Authorized>
</AuthorizeView>
<main class="hub-main">
<div class="hub-content">
@Body
</div>
</main>
</div>
@code {
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private IIdentityService IdentityService { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
private bool _isSyncing = false;
protected override async Task OnInitializedAsync()
{
if (_isSyncing) return;
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.Identity?.IsAuthenticated ?? true)
{
_isSyncing = true;
// Try to sync with server cookie
await IdentityService.GetProfileAsync();
}
}
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/", true);
}
}
@@ -1,193 +0,0 @@
.hub-container {
display: flex;
width: 100vw;
height: 100vh;
background: #121212;
color: #e0e0e0;
overflow: hidden;
}
::deep .hub-sidebar {
width: 260px;
height: 100%;
background: #161616;
border-right: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
z-index: 100;
flex-shrink: 0;
}
::deep .sidebar-header {
padding: 2.5rem 1.5rem;
}
::deep .logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
::deep .logo-icon {
color: var(--nexus-neon);
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.4));
}
::deep .logo-text {
font-family: var(--nexus-font-serif);
font-size: 1.5rem;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.01em;
}
::deep .sidebar-nav {
flex: 1;
padding: 0;
display: flex;
flex-direction: column;
}
::deep .nav-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
color: #A0A0A0;
text-decoration: none;
transition: all 0.2s ease;
border-left: 3px solid transparent;
font-family: var(--nexus-font-sans);
font-size: 0.9rem;
font-weight: 500;
}
::deep .nav-item:hover {
background: rgba(255, 255, 255, 0.02);
color: #ffffff;
}
::deep .nav-item.active {
color: #ffffff;
background: rgba(0, 255, 153, 0.03);
border-left: 3px solid var(--nexus-neon);
}
::deep .nav-item.active .nav-icon {
color: var(--nexus-neon);
}
::deep .nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
opacity: 0.7;
transition: opacity 0.2s;
}
::deep .nav-item:hover .nav-icon,
::deep .nav-item.active .nav-icon {
opacity: 1;
}
::deep .sidebar-footer {
padding: 1.25rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
}
::deep .user-brief {
display: flex;
align-items: center;
gap: 0.75rem;
overflow: hidden;
}
::deep .user-avatar {
width: 32px;
height: 32px;
background: #222;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 600;
color: #A0A0A0;
flex-shrink: 0;
}
::deep .user-details {
display: flex;
flex-direction: column;
overflow: hidden;
}
::deep .user-name {
font-size: 0.85rem;
font-weight: 500;
color: #A0A0A0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
::deep .logout-btn {
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 0.4rem;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
::deep .logout-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
.hub-main {
flex: 1;
height: 100%;
overflow-y: auto;
background: radial-gradient(circle at center, #1a1a1a 0%, #121212 100%);
}
.hub-content {
padding: 2.5rem;
min-height: 100%;
}
::deep .hub-loading {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
::deep .nexus-loader {
width: 32px;
height: 32px;
border: 2px solid rgba(0, 255, 153, 0.1);
border-top-color: var(--nexus-neon);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
filter: drop-shadow(0 0 5px var(--nexus-neon));
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -10,23 +10,19 @@
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger @inject Microsoft.Extensions.Logging.ILogger<MainLayout> Logger
@implements IDisposable @implements IDisposable
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")"> <AuthorizeView>
<div class="reader-pane"> <Authorized>
<main> <div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
@Body <div class="reader-pane">
</main> <main>
<AuthorizeView> @Body
<Authorized> </main>
<ReaderFooter /> <ReaderFooter />
</Authorized> </div>
</AuthorizeView>
</div>
<AuthorizeView>
<Authorized>
<div class="resizer" id="sidebar-resizer"></div> <div class="resizer" id="sidebar-resizer"></div>
<div class="intelligence-sidebar"> <div class="intelligence-sidebar">
@@ -38,6 +34,12 @@
Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" /> Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span> <span>Asystent AI</span>
</div> </div>
<div class="user-profile">
<span class="user-email">@context.User.Identity?.Name</span>
<button class="logout-btn" @onclick="HandleLogout">Logout</button>
</div>
<button class="close-btn">×</button> <button class="close-btn">×</button>
</div> </div>
@@ -50,15 +52,18 @@
</div> </div>
</div> </div>
</div> </div>
</Authorized> </div>
<Authorizing> </Authorized>
<div class="app-preloader"> <Authorizing>
<div class="preloader-spinner"></div> <div class="app-preloader">
<div class="preloader-text">Weryfikacja...</div> <div class="preloader-spinner"></div>
</div> <div class="preloader-text">Weryfikacja...</div>
</Authorizing> </div>
</AuthorizeView> </Authorizing>
</div> <NotAuthorized>
@Body
</NotAuthorized>
</AuthorizeView>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred. An unhandled error has occurred.
@@ -72,8 +77,8 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
FocusMode.OnFocusModeChanged += HandleUpdate; FocusMode.OnFocusModeChanged += StateHasChanged;
QuizService.OnQuizUpdated += HandleUpdate; QuizService.OnQuizUpdated += StateHasChanged;
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
if (context.IsSuccess) if (context.IsSuccess)
@@ -88,7 +93,11 @@
} }
} }
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/", true);
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@@ -106,11 +115,9 @@
} }
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
FocusMode.OnFocusModeChanged -= HandleUpdate; FocusMode.OnFocusModeChanged -= StateHasChanged;
QuizService.OnQuizUpdated -= HandleUpdate; QuizService.OnQuizUpdated -= StateHasChanged;
} }
} }
@@ -20,10 +20,9 @@
main { main {
flex: 1; flex: 1;
overflow: hidden; overflow-y: auto;
overflow-x: hidden;
position: relative; position: relative;
display: flex;
flex-direction: column;
} }
.intelligence-sidebar { .intelligence-sidebar {
@@ -33,7 +32,7 @@ main {
height: 100%; height: 100%;
background: #0d0d0d; background: #0d0d0d;
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3); box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
border-left: 1px solid rgba(255, 255, 255, 0.1); border-left: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden; overflow: hidden;
z-index: 10; z-index: 10;
} }
@@ -21,8 +21,9 @@
<div class="social-auth"> <div class="social-auth">
<button type="button" class="btn-google-auth" @onclick="HandleGoogleLogin"> <button type="button" class="btn-google-auth" @onclick="HandleGoogleLogin">
<img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png" alt="Google" <img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png"
style="width: 20px !important; height: 20px !important; flex-shrink: 0;" /> alt="Google"
style="width: 20px !important; height: 20px !important; flex-shrink: 0;" />
<span>Zaloguj się przez Google</span> <span>Zaloguj się przez Google</span>
</button> </button>
</div> </div>
@@ -46,8 +47,7 @@
<div class="field-icon"> <div class="field-icon">
<NexusIcon Name="lock" Size="18" /> <NexusIcon Name="lock" Size="18" />
</div> </div>
<InputText id="password" type="@(_showPassword ? "text" : "password")" <InputText id="password" type="@(_showPassword ? "text" : "password")" @bind-Value="_loginModel.Password" placeholder="Hasło" class="field-input" />
@bind-Value="_loginModel.Password" placeholder="Hasło" class="field-input" />
<button type="button" class="toggle-visibility" @onclick="TogglePassword"> <button type="button" class="toggle-visibility" @onclick="TogglePassword">
<NexusIcon Name="@(_showPassword ? "eye-off" : "eye")" Size="18" /> <NexusIcon Name="@(_showPassword ? "eye-off" : "eye")" Size="18" />
</button> </button>
@@ -59,13 +59,6 @@
<div class="auth-error">@_errorMessage</div> <div class="auth-error">@_errorMessage</div>
} }
<div class="auth-options">
<label class="remember-me">
<InputCheckbox @bind-Value="_loginModel.RememberMe" />
<span>Zapamiętaj mnie</span>
</label>
</div>
<button type="submit" class="btn-submit-auth" disabled="@_isSubmitting"> <button type="submit" class="btn-submit-auth" disabled="@_isSubmitting">
@if (_isSubmitting) @if (_isSubmitting)
{ {
@@ -84,37 +77,17 @@
</div> </div>
<div class="auth-legal"> <div class="auth-legal">
Korzystając z usługi, akceptujesz <a href="/terms">Regulamin</a> i <a href="/privacy">Politykę Korzystając z usługi, akceptujesz <a href="/terms">Regulamin</a> i <a href="/privacy">Politykę Prywatności</a>
Prywatności</a>
</div> </div>
</div> </div>
</div> </div>
@code { @code {
[Parameter]
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorCode { get; set; }
private LoginModel _loginModel = new(); private LoginModel _loginModel = new();
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
private bool _showPassword; private bool _showPassword;
protected override void OnInitialized()
{
if (!string.IsNullOrEmpty(ErrorCode))
{
_errorMessage = ErrorCode switch
{
"ExternalLoginFailed" => "Nie udało się zalogować przez Google. Spróbuj ponownie.",
"ProvisioningFailed" => "Wystąpił błąd podczas przygotowywania Twojego konta.",
"UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.",
"LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.",
_ => "Wystąpił nieoczekiwany błąd podczas logowania."
};
}
}
private async Task HandleLogin() private async Task HandleLogin()
{ {
_isSubmitting = true; _isSubmitting = true;
@@ -122,11 +95,11 @@
try try
{ {
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe); var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password);
if (success) NavigationManager.NavigateTo("/"); if (success) NavigationManager.NavigateTo("/");
else _errorMessage = "Nieprawidłowy e-mail lub hasło."; else _errorMessage = "Nieprawidłowy e-mail lub hasło.";
} }
catch (Exception ex) { _errorMessage = $"Wystąpił błąd logowania: {ex.Message}."; } catch (Exception) { _errorMessage = "Wystąpił błąd logowania."; }
finally { _isSubmitting = false; } finally { _isSubmitting = false; }
} }
@@ -141,7 +114,5 @@
[System.ComponentModel.DataAnnotations.Required] [System.ComponentModel.DataAnnotations.Required]
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
} }
} }
@@ -1,5 +1,4 @@
@page "/account/profile" @page "/account/profile"
@page "/profile"
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@@ -7,102 +6,96 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="profile-page-container"> <div class="profile-dashboard">
<div class="background-radial"></div>
<div class="mesh-overlay"></div> <div class="mesh-overlay"></div>
@if (_profile == null) @if (_profile == null)
{ {
<div class="loading-state"> <div class="loading-overlay">
<div class="nexus-loader"></div> <div class="nexus-loader"></div>
<p>Ładowanie systemu...</p> <p>Ładowanie Twojego profilu...</p>
</div> </div>
} }
else else
{ {
<div class="profile-content"> <div class="dashboard-content">
<!-- Identity Section --> <header class="dashboard-header">
<section class="identity-section"> <div class="user-meta">
<div class="avatar-container"> <div class="user-avatar">
<div class="avatar-glow"></div>
<div class="avatar-inner">
@(_profile.Email[0].ToString().ToUpper()) @(_profile.Email[0].ToString().ToUpper())
</div> </div>
</div> <div class="user-info">
<h1>@_profile.Email</h1>
<div class="user-titles"> <div class="plan-info">
<h1 class="username">@_profile.Email.Split('@')[0]</h1> <span class="badge @(_profile.CurrentPlan.ToLower())">@_profile.CurrentPlan Plan</span>
<span class="system-rank">[Nexus_Explorer_@(_profile.TenantId.ToString()[..4])]</span> <span class="tenant-id">ID: @_profile.TenantId.ToString()[..8]...</span>
</div> </div>
</section>
<!-- Metrics Grid -->
<div class="metrics-grid">
<!-- Intelligence Card -->
<div class="metric-card glass-panel">
<div class="card-header">
<NexusIcon Name="robot" Size="24" Color="var(--nexus-neon)" />
<h3>Interfejs AI</h3>
</div> </div>
<div class="card-body"> </div>
<div class="token-usage"> <div class="header-actions">
<div class="usage-values"> <button class="btn-logout" @onclick="HandleLogout">
<span class="current">@_profile.AITokensUsed</span> <NexusIcon Name="lock" Size="16" />
<span class="separator">/</span> Wyloguj się
<span class="total">@_profile.AITokenLimit</span> </button>
</div>
</header>
<div class="stats-grid">
<!-- AI Token Card -->
<div class="stat-card usage-card">
<div class="card-icon">
<NexusIcon Name="robot" Size="24" />
</div>
<div class="card-info">
<h3>Wykorzystanie AI</h3>
<div class="token-numbers">
<span class="tokens-used">@_profile.AITokensUsed</span>
<span class="tokens-limit">/ @_profile.AITokenLimit tokenów</span>
</div>
<div class="usage-bar">
<div class="usage-fill" style="width: @(CalculateProgress())%"></div>
</div>
<p class="usage-desc">Limit odnawia się w następnym cyklu rozliczeniowym.</p>
</div>
</div>
<!-- Learning Progress Card -->
<div class="stat-card learning-card">
<div class="card-icon">
<NexusIcon Name="mail" Size="24" />
</div>
<div class="card-info">
<h3>Aktywna Nauka</h3>
<div class="learning-metrics">
<div class="metric">
<span class="label">Średni wynik quizów</span>
<span class="value">@_profile.AverageQuizScore%</span>
</div> </div>
<div class="usage-progress"> <div class="metric">
<div class="progress-bar" style="width: @(CalculateProgress())%"></div> <span class="label">Ostatnio czytane</span>
<span class="value truncate">@_profile.LastReadBookTitle</span>
</div> </div>
</div> </div>
<span class="metric-label">Wykorzystane Jednostki Mocy</span>
</div>
</div>
<!-- Sync Card -->
<div class="metric-card glass-panel">
<div class="card-header">
<NexusIcon Name="activity" Size="24" Color="var(--nexus-neon)" />
<h3>Wydajność Nauki</h3>
</div>
<div class="card-body">
<div class="score-display">
<span class="score-value">@_profile.AverageQuizScore%</span>
<span class="score-label">Średni Wynik Asymilacji</span>
</div>
<div class="last-book">
<NexusIcon Name="book-open" Size="14" />
<span class="truncate">@_profile.LastReadBookTitle</span>
</div>
</div>
</div>
<!-- Account Status Card -->
<div class="metric-card glass-panel full-width">
<div class="card-header">
<NexusIcon Name="shield" Size="24" Color="var(--nexus-neon)" />
<h3>Status Autoryzacji</h3>
</div>
<div class="card-body status-layout">
<div class="status-info">
<span class="plan-badge @(_profile.CurrentPlan.ToLower())">@_profile.CurrentPlan Protocol</span>
<span class="tenant-tag">Node: @_profile.TenantId.ToString().ToUpper()</span>
</div>
<div class="profile-actions">
<button class="btn-nexus secondary" @onclick="HandleUpgrade">Zarządzaj Subskrypcją</button>
<button class="btn-nexus logout" @onclick="HandleLogout">
<NexusIcon Name="log-out" Size="18" />
Wyloguj
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<section class="subscription-section">
<div class="section-card">
<div class="section-info">
<h2>Zarządzaj subskrypcją</h2>
<p>Zmień swój plan, aby zwiększyć limit tokenów AI i odblokować funkcje premium.</p>
</div>
<button class="btn-upgrade" @onclick="HandleUpgrade">
Przejdź do panelu płatności
</button>
</div>
</section>
</div> </div>
} }
<div class="decoration decoration-top">NXS-SYS-v10</div> <div class="decoration-star top-left">✦</div>
<div class="decoration decoration-bottom">IDENTITY-CORE-ENCRYPTED</div> <div class="decoration-star bottom-right">✦</div>
</div> </div>
@code { @code {
@@ -111,7 +104,6 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_profile = await IdentityService.GetProfileAsync(); _profile = await IdentityService.GetProfileAsync();
StateHasChanged();
} }
private int CalculateProgress() private int CalculateProgress()
@@ -1,361 +1,256 @@
.profile-page-container { .profile-dashboard {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background-color: #0a0c10; background-color: #121418;
color: #e0e6ed; color: white;
overflow-x: hidden; font-family: 'Inter', sans-serif;
padding: 60px 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 80px 20px; overflow-x: hidden;
font-family: var(--nexus-font-sans);
}
.background-radial {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 800px;
height: 800px;
background: radial-gradient(circle, rgba(0, 255, 153, 0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
} }
.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 2px 2px, rgba(255,255,255,0.02) 1px, transparent 0);
background-size: 32px 32px; background-size: 40px 40px;
z-index: 1; z-index: 1;
} }
.profile-content { .dashboard-content {
position: relative; position: relative;
width: 100%;
max-width: 1000px;
z-index: 10; z-index: 10;
width: 100%;
max-width: 900px;
display: flex;
flex-direction: column;
align-items: center;
gap: 60px;
} }
/* Identity Section */ .dashboard-header {
.identity-section { display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 48px;
padding: 0 10px;
}
.user-meta {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 24px; gap: 24px;
text-align: center;
} }
.avatar-container { .user-avatar {
position: relative; width: 80px;
width: 140px; height: 80px;
height: 140px; background: linear-gradient(135deg, #44ff77 0%, #2ecc71 100%);
display: flex;
justify-content: center;
align-items: center;
}
.avatar-inner {
width: 120px;
height: 120px;
background: #151921;
border: 2px solid var(--nexus-neon);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-size: 3.5rem;
font-weight: 800;
color: var(--nexus-neon);
z-index: 2;
box-shadow: 0 0 30px rgba(0, 255, 153, 0.2), inset 0 0 20px rgba(0, 255, 153, 0.1);
position: relative;
}
.avatar-glow {
position: absolute;
width: 140px;
height: 140px;
border: 1px solid rgba(0, 255, 153, 0.3);
border-radius: 50%;
animation: pulse-ring 3s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes pulse-ring {
0% { transform: scale(0.95); opacity: 0.8; }
50% { transform: scale(1.1); opacity: 0.2; }
100% { transform: scale(0.95); opacity: 0.8; }
}
.username {
font-family: var(--nexus-font-serif);
font-size: 2.8rem;
font-weight: 700;
margin: 0;
letter-spacing: -0.01em;
color: #ffffff;
}
.system-rank {
font-family: 'Inter', 'Courier New', Courier, monospace;
font-size: 0.9rem;
color: var(--nexus-neon);
text-transform: uppercase;
letter-spacing: 0.2em;
opacity: 0.8;
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
width: 100%;
}
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px; border-radius: 24px;
padding: 32px;
transition: all 0.3s ease;
}
.glass-panel:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 255, 153, 0.2);
transform: translateY(-4px);
}
.metric-card {
display: flex; display: flex;
flex-direction: column; justify-content: center;
gap: 24px; align-items: center;
font-size: 2.5rem;
font-weight: 800;
color: #000;
box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2);
} }
.full-width { .user-info h1 {
grid-column: span 2; font-size: 1.8rem;
font-weight: 700;
margin: 0 0 8px;
letter-spacing: -0.02em;
} }
.card-header { .plan-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.card-header h3 { .badge {
padding: 4px 12px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge.pro { background: rgba(68, 255, 119, 0.1); color: #44ff77; border: 1px solid rgba(68, 255, 119, 0.2); }
.badge.free { background: rgba(255, 255, 255, 0.05); color: #888; border: 1px solid rgba(255, 255, 255, 0.1); }
.tenant-id { font-size: 0.8rem; color: #555; }
.btn-logout {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: #888;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; cursor: pointer;
letter-spacing: 0.1em; transition: all 0.2s;
color: #a0aec0;
margin: 0;
} }
.card-body { .btn-logout:hover {
background: rgba(255, 77, 77, 0.05);
border-color: rgba(255, 77, 77, 0.2);
color: #ff4d4d;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 48px;
}
.stat-card {
background: #1c1f24;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 32px;
display: flex; display: flex;
flex-direction: column; gap: 24px;
gap: 12px; transition: transform 0.3s ease;
} }
/* Usage Progress */ .stat-card:hover { transform: translateY(-5px); }
.token-usage {
.card-icon {
width: 56px;
height: 56px;
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
display: flex; display: flex;
flex-direction: column; justify-content: center;
gap: 12px; align-items: center;
color: #44ff77;
flex-shrink: 0;
} }
.usage-values { .card-info h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 16px;
color: #e0e0e0;
}
.token-numbers {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 8px; gap: 8px;
margin-bottom: 12px;
} }
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: #fff; line-height: 1; } .tokens-used { font-size: 2rem; font-weight: 800; color: white; }
.usage-values .separator { font-size: 1.2rem; color: #4a5568; } .tokens-limit { font-size: 1rem; color: #555; font-weight: 500; }
.usage-values .total { font-size: 1.2rem; color: #718096; font-weight: 600; }
.usage-progress { .usage-bar {
width: 100%; width: 100%;
height: 6px; height: 8px;
background: rgba(255, 255, 255, 0.05); background: #15181c;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
margin-bottom: 12px;
} }
.progress-bar { .usage-fill {
height: 100%; height: 100%;
background: var(--nexus-neon); background: #44ff77;
box-shadow: 0 0 15px rgba(0, 255, 153, 0.5); box-shadow: 0 0 15px rgba(68, 255, 119, 0.3);
border-radius: 10px; border-radius: 10px;
} }
.metric-label { .usage-desc { font-size: 0.8rem; color: #555; margin: 0; }
font-size: 0.75rem;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Score Display */ .learning-metrics {
.score-display {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px;
} }
.score-value { .metric {
font-size: 2.5rem;
font-weight: 800;
color: var(--nexus-neon);
line-height: 1;
}
.score-label {
font-size: 0.75rem;
color: #718096;
text-transform: uppercase;
margin-top: 4px;
}
.last-book {
margin-top: 12px;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 8px; gap: 4px;
padding: 10px 16px;
background: rgba(0, 255, 153, 0.05);
border-radius: 12px;
font-size: 0.85rem;
color: #cbd5e0;
} }
.truncate { .metric .label { font-size: 0.85rem; color: #666; font-weight: 500; }
white-space: nowrap; .metric .value { font-size: 1.2rem; font-weight: 700; color: white; }
overflow: hidden; .metric .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
text-overflow: ellipsis;
max-width: 300px;
}
/* Status Layout */ .subscription-section { margin-top: 48px; }
.status-layout {
flex-direction: row; .section-card {
background: linear-gradient(90deg, #1c1f24 0%, #23272e 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 40px;
display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 40px;
} }
.status-info { .section-info h2 { font-size: 1.5rem; font-weight: 700; margin: 0 0 12px; }
display: flex; .section-info p { color: #888; margin: 0; line-height: 1.6; max-width: 500px; }
flex-direction: column;
gap: 8px;
}
.plan-badge { .btn-upgrade {
padding: 6px 14px; padding: 16px 32px;
border-radius: 8px; background: #44ff77;
font-size: 0.8rem; border: none;
font-weight: 800; border-radius: 16px;
text-transform: uppercase; color: #000;
letter-spacing: 0.05em; font-size: 1rem;
width: fit-content;
}
.plan-badge.pro {
background: rgba(0, 255, 153, 0.1);
color: var(--nexus-neon);
border: 1px solid rgba(0, 255, 153, 0.2);
}
.tenant-tag {
font-family: monospace;
font-size: 0.75rem;
color: #4a5568;
}
.profile-actions {
display: flex;
gap: 16px;
}
.btn-nexus {
padding: 12px 24px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s;
display: flex; white-space: nowrap;
align-items: center;
gap: 8px;
border: none;
} }
.btn-nexus.secondary { .btn-upgrade:hover {
background: rgba(255, 255, 255, 0.05); transform: scale(1.05);
color: #fff; box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
} }
.btn-nexus.secondary:hover { .loading-overlay {
background: rgba(255, 255, 255, 0.1);
}
.btn-nexus.logout {
background: rgba(255, 71, 87, 0.1);
color: #ff4757;
border: 1px solid rgba(255, 71, 87, 0.2);
}
.btn-nexus.logout:hover {
background: #ff4757;
color: #fff;
}
/* Decorations */
.decoration {
position: absolute;
font-family: monospace;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.05);
letter-spacing: 0.3em;
pointer-events: none;
}
.decoration-top { top: 40px; left: 40px; }
.decoration-bottom { bottom: 40px; right: 40px; }
/* Loading State */
.loading-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 24px; gap: 20px;
} }
.nexus-loader { .nexus-loader {
width: 60px; width: 48px;
height: 60px; height: 48px;
border: 4px solid rgba(0, 255, 153, 0.1); border: 3px solid rgba(68, 255, 119, 0.1);
border-top-color: var(--nexus-neon); border-top-color: #44ff77;
border-radius: 50%; border-radius: 50%;
animation: spin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite; animation: spin 1s linear infinite;
filter: drop-shadow(0 0 10px var(--nexus-neon));
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) { .decoration-star {
.metrics-grid { grid-template-columns: 1fr; } position: absolute;
.full-width { grid-column: span 1; } font-size: 48px;
.status-layout { flex-direction: column; align-items: flex-start; gap: 24px; } color: rgba(255, 255, 255, 0.03);
.profile-actions { width: 100%; flex-direction: column; } z-index: 1;
.btn-nexus { width: 100%; justify-content: center; } }
.username { font-size: 2.2rem; }
.top-left { top: 40px; left: 40px; }
.bottom-right { bottom: 40px; right: 40px; }
@media (max-width: 768px) {
.dashboard-header { flex-direction: column; align-items: flex-start; gap: 24px; }
.header-actions { width: 100%; }
.btn-logout { width: 100%; justify-content: center; }
.stats-grid { grid-template-columns: 1fr; }
.section-card { flex-direction: column; text-align: center; }
} }
@@ -1,112 +0,0 @@
@page "/"
@using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Components.Atoms
@attribute [Authorize]
<PageTitle>Dashboard | Nexus Reader</PageTitle>
<div class="dashboard-container">
<!-- Top Profile Section -->
<header class="profile-header">
<div class="header-grid-bg"></div>
<div class="profile-visual">
<div class="avatar-wrapper">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=Nexus" alt="Profile" class="profile-img" />
<div class="avatar-glow"></div>
</div>
<h1 class="username">[User_Explorer1988]</h1>
<div class="status-pills">
<div class="status-pill">
<span class="pill-label">Books Read:</span>
<span class="pill-value">12</span>
</div>
<div class="status-pill">
<span class="pill-label">Concepts Mapped:</span>
<span class="pill-value">450</span>
</div>
<div class="status-pill">
<span class="pill-label">Quiz Mastery:</span>
<span class="pill-value">88%</span>
</div>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="dashboard-content">
<h2 class="section-title">User: [User Name]</h2>
<div class="main-grid">
<!-- Current Reading Card -->
<section class="reading-card glass-panel">
<div class="card-header">
<h3>Current Reading: The History of Art (Chapter 3: Renaissance Masters)</h3>
</div>
<div class="card-body">
<div class="reading-thumb">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg/402px-Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg" alt="Current Book" />
</div>
<div class="reading-info">
<div class="progress-section">
<span class="chapter-label">Chapter 3: Renaissance Masters</span>
<div class="progress-container">
<div class="progress-bar" style="width: 45%;">
<div class="progress-bubble">45%</div>
</div>
</div>
<span class="progress-detail">Progress: 45% - Section 3.2</span>
</div>
<p class="reading-desc">
The history of art is a mart eccohow, and andosum and tomeam of the inner otium or orer the sllinest arts and emoti mooners in the tour of arts and specillers. Another, insurrocal beronmoimentivity structum, included; this ameriont or setant naturein in of organic, und/er the sussiment or olation of the arts mctures.
</p>
<div class="card-actions">
<button class="btn-nexus primary">Continue Reading</button>
<button class="btn-nexus secondary">Open Reader</button>
</div>
</div>
</div>
</section>
<div class="secondary-grid">
<!-- Knowledge Integration -->
<section class="integration-card glass-panel">
<div class="panel-header">
<h4>Knowledge Integration Progress</h4>
<NexusIcon Name="arrow-right" Size="16" />
</div>
<div class="graph-placeholder">
<div class="graph-node central"></div>
<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">TU JESTEŚ</div>
</div>
</section>
<!-- Quiz Summary -->
<section class="quiz-card glass-panel">
<div class="panel-header">
<h4>Quiz Summary: Key Thinkers</h4>
<NexusIcon Name="arrow-right" Size="16" />
</div>
<div class="quiz-preview">
<p class="question">Który artysta namalował 'Ostatnią Wieczerzę'?</p>
<div class="quiz-options">
<div class="quiz-option active">
<span class="option-letter">A)</span> Michal Anioł
</div>
<div class="quiz-option">
<span class="option-letter">B)</span> Leonardo da Vinci
</div>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
@code {
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
}
@@ -1,358 +0,0 @@
.dashboard-container {
min-height: 100%;
display: flex;
flex-direction: column;
animation: fade-in 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* --- Profile Header --- */
.profile-header {
position: relative;
padding: 4rem 2rem 3rem;
display: flex;
justify-content: center;
overflow: hidden;
background: #0D0D0D;
}
.header-grid-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 60px 60px;
background-position: center;
mask-image: radial-gradient(circle at center, black, transparent 80%);
}
.profile-visual {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.avatar-wrapper {
position: relative;
width: 120px;
height: 120px;
}
.profile-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 3px solid #1a1a1a;
position: relative;
z-index: 2;
background: #222;
}
.avatar-glow {
position: absolute;
inset: -5px;
border-radius: 50%;
background: var(--nexus-neon);
filter: blur(20px);
opacity: 0.4;
z-index: 1;
animation: pulse-glow 3s infinite;
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); opacity: 0.4; }
50% { transform: scale(1.05); opacity: 0.6; }
}
.username {
font-family: var(--nexus-font-sans);
font-size: 1.25rem;
font-weight: 500;
color: #ffffff;
letter-spacing: 1px;
}
.status-pills {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.status-pill {
padding: 0.6rem 1.25rem;
background: rgba(0, 255, 153, 0.05);
border: 1px solid rgba(0, 255, 153, 0.3);
border-radius: 100px;
display: flex;
gap: 0.5rem;
font-size: 0.9rem;
box-shadow: 0 0 15px rgba(0, 255, 153, 0.1);
}
.pill-label { color: #A0A0A0; }
.pill-value { color: #ffffff; font-weight: 600; }
/* --- Dashboard Content --- */
.dashboard-content {
padding: 3rem 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.section-title {
font-family: var(--nexus-font-serif);
font-size: 2rem;
margin-bottom: 2rem;
color: #ffffff;
}
.main-grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 2rem;
}
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 1.5rem;
}
/* Reading Card */
.reading-card {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.reading-card h3 {
font-size: 1.1rem;
font-weight: 600;
color: #E0E0E0;
margin: 0;
}
.card-body {
display: flex;
gap: 2rem;
}
.reading-thumb {
width: 120px;
flex-shrink: 0;
}
.reading-thumb img {
width: 100%;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.reading-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chapter-label {
font-size: 0.85rem;
color: #A0A0A0;
}
.progress-container {
height: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
position: relative;
}
.progress-bar {
height: 100%;
background: var(--nexus-neon);
border-radius: 4px;
position: relative;
box-shadow: 0 0 10px rgba(0, 255, 153, 0.3);
}
.progress-bubble {
position: absolute;
right: -20px;
top: -30px;
background: var(--nexus-neon);
color: #000;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 700;
}
.progress-detail {
font-size: 0.8rem;
color: #666;
}
.reading-desc {
font-size: 0.85rem;
line-height: 1.6;
color: #888;
margin: 0;
}
.card-actions {
display: flex;
gap: 1rem;
}
/* Secondary Grid Items */
.secondary-grid {
display: flex;
flex-direction: column;
gap: 2rem;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.panel-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #E0E0E0;
}
/* Graph Placeholder */
.graph-placeholder {
height: 180px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.graph-node {
position: absolute;
border-radius: 50%;
background: #333;
border: 1px solid rgba(255,255,255,0.1);
}
.graph-node.central {
width: 40px;
height: 40px;
background: var(--nexus-neon);
box-shadow: 0 0 20px rgba(0, 255, 153, 0.4);
}
.graph-node.satellite {
width: 20px;
height: 20px;
transform: rotate(var(--angle)) translateY(var(--dist));
}
.active-node-label {
position: absolute;
bottom: 20px;
right: 20px;
padding: 4px 12px;
background: rgba(0, 255, 153, 0.1);
border: 1px solid var(--nexus-neon);
border-radius: 4px;
font-size: 0.7rem;
color: var(--nexus-neon);
font-weight: 700;
}
/* Quiz Preview */
.quiz-preview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.question {
font-size: 0.95rem;
color: #E0E0E0;
}
.quiz-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.quiz-option {
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
font-size: 0.9rem;
display: flex;
gap: 0.75rem;
cursor: pointer;
}
.quiz-option.active {
background: rgba(0, 255, 153, 0.05);
border-color: var(--nexus-neon);
color: var(--nexus-neon);
}
.option-letter {
font-weight: 700;
}
/* Buttons */
.btn-nexus {
padding: 0.75rem 1.25rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-nexus.primary {
background: var(--nexus-neon);
color: #000;
}
.btn-nexus.secondary {
background: rgba(255, 255, 255, 0.05);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-nexus:hover {
transform: translateY(-2px);
filter: brightness(1.1);
}
@media (max-width: 1024px) {
.main-grid {
grid-template-columns: 1fr;
}
}
+7 -10
View File
@@ -1,5 +1,4 @@
@page "/reader" @page "/"
@layout ReaderLayout
@attribute [Authorize] @attribute [Authorize]
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@implements IAsyncDisposable @implements IAsyncDisposable
@@ -23,8 +22,8 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
QuizState.OnQuizRequested += HandleQuizRequestedAsync; QuizState.OnQuizRequested += HandleQuizRequested;
FocusMode.OnFocusModeChanged += HandleUpdate; FocusMode.OnFocusModeChanged += StateHasChanged;
await FocusMode.InitializeAsync(); await FocusMode.InitializeAsync();
} }
@@ -55,18 +54,16 @@
} }
} }
private async Task HandleQuizRequestedAsync(string blockId) private void HandleQuizRequested(string blockId)
{ {
_activeQuizBlockId = blockId; _activeQuizBlockId = blockId;
await InvokeAsync(StateHasChanged); StateHasChanged();
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
QuizState.OnQuizRequested -= HandleQuizRequestedAsync; QuizState.OnQuizRequested -= HandleQuizRequested;
FocusMode.OnFocusModeChanged -= HandleUpdate; FocusMode.OnFocusModeChanged -= StateHasChanged;
if (_interopModule != null && _keydownHandler != null) if (_interopModule != null && _keydownHandler != null)
{ {
@@ -1,8 +1,8 @@
.home-reader-container { .home-reader-container {
width: 100%; width: 100%;
height: 100%;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 2rem;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -1,17 +0,0 @@
@page "/library"
@attribute [Authorize]
<div class="library-page">
<h1>Biblioteka</h1>
<p>Twoja kolekcja książek i dokumentów pojawi się tutaj wkrótce.</p>
</div>
<style>
.library-page {
padding: 2rem;
}
h1 {
margin-bottom: 1rem;
color: #fff;
}
</style>
@@ -1,10 +1,5 @@
@page "/not-found" @page "/not-found"
@layout MainHubLayout @layout MainLayout
<div class="not-found-preloader"> <h3>Not Found</h3>
<div class="preloader-robot"> <p>Sorry, the content you are looking for does not exist.</p>
<NexusIcon Name="robot" Size="64" class="neon-pulse" />
<div class="scan-line"></div>
</div>
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Synchronizowanie przestrzeni Nexus...</NexusTypography>
</div>
@@ -1,42 +0,0 @@
.not-found-preloader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 2rem;
}
.preloader-robot {
position: relative;
padding: 1rem;
}
.scan-line {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--nexus-neon);
box-shadow: 0 0 10px var(--nexus-neon);
animation: scan 2s linear infinite;
}
@keyframes scan {
0% { top: 0; }
50% { top: 100%; }
100% { top: 0; }
}
.neon-pulse {
color: var(--nexus-neon);
filter: drop-shadow(0 0 5px var(--nexus-neon));
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
@@ -1,17 +0,0 @@
@page "/settings"
@attribute [Authorize]
<div class="settings-page">
<h1>Ustawienia</h1>
<p>Konfiguracja Twojego konta i preferencji czytania.</p>
</div>
<style>
.settings-page {
padding: 2rem;
}
h1 {
margin-bottom: 1rem;
color: #fff;
}
</style>
+1 -4
View File
@@ -2,16 +2,13 @@
<ChildContent> <ChildContent>
<Router AppAssembly="@typeof(Routes).Assembly"> <Router AppAssembly="@typeof(Routes).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainHubLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
<NotAuthorized> <NotAuthorized>
<RedirectToLogin /> <RedirectToLogin />
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found> </Found>
<NotFound>
<NexusReader.UI.Shared.Pages.NotFound />
</NotFound>
</Router> </Router>
</ChildContent> </ChildContent>
<ErrorContent Context="ex"> <ErrorContent Context="ex">
@@ -6,7 +6,7 @@ public sealed class FocusModeService : IFocusModeService
{ {
private readonly IJSRuntime _jsRuntime; private readonly IJSRuntime _jsRuntime;
public bool IsFocusModeActive { get; private set; } public bool IsFocusModeActive { get; private set; }
public event Func<Task>? OnFocusModeChanged; public event Action? OnFocusModeChanged;
public FocusModeService(IJSRuntime jsRuntime) public FocusModeService(IJSRuntime jsRuntime)
{ {
@@ -21,7 +21,7 @@ public sealed class FocusModeService : IFocusModeService
if (value == "true" && !IsFocusModeActive) if (value == "true" && !IsFocusModeActive)
{ {
IsFocusModeActive = true; IsFocusModeActive = true;
if (OnFocusModeChanged != null) await OnFocusModeChanged(); OnFocusModeChanged?.Invoke();
} }
} }
catch catch
@@ -33,7 +33,7 @@ public sealed class FocusModeService : IFocusModeService
public async Task ToggleAsync() public async Task ToggleAsync()
{ {
IsFocusModeActive = !IsFocusModeActive; IsFocusModeActive = !IsFocusModeActive;
if (OnFocusModeChanged != null) await OnFocusModeChanged(); OnFocusModeChanged?.Invoke();
try try
{ {
@@ -3,7 +3,7 @@ namespace NexusReader.UI.Shared.Services;
public interface IFocusModeService public interface IFocusModeService
{ {
bool IsFocusModeActive { get; } bool IsFocusModeActive { get; }
event Func<Task>? OnFocusModeChanged; event Action? OnFocusModeChanged;
Task InitializeAsync(); Task InitializeAsync();
Task ToggleAsync(); Task ToggleAsync();
} }
@@ -8,12 +8,12 @@ public interface IKnowledgeGraphService
string? ActiveNodeId { get; } string? ActiveNodeId { get; }
bool IsLoading { get; } bool IsLoading { get; }
event Func<Task>? OnGraphUpdated; event Action? OnGraphUpdated;
event Func<string, Task>? OnActiveNodeChanged; event Action<string>? OnActiveNodeChanged;
event Func<bool, Task>? OnLoadingChanged; event Action<bool>? OnLoadingChanged;
Task UpdateGraph(GraphDataDto newData); void UpdateGraph(GraphDataDto newData);
Task SetActiveNode(string nodeId); void SetActiveNode(string nodeId);
Task SetLoading(bool isLoading); void SetLoading(bool isLoading);
Task Clear(); void Clear();
} }
@@ -9,11 +9,11 @@ public interface IQuizStateService
bool IsHydrating { get; } bool IsHydrating { get; }
bool HasNewQuiz { get; } bool HasNewQuiz { get; }
event Func<string, Task>? OnQuizRequested; event Action<string>? OnQuizRequested;
event Func<Task>? OnQuizUpdated; event Action? OnQuizUpdated;
Task RequestQuiz(string blockId); void RequestQuiz(string blockId);
Task SetQuiz(string? blockId, QuizDto? quiz); void SetQuiz(string? blockId, QuizDto quiz);
Task SetHydrating(bool hydrating); void SetHydrating(bool hydrating);
Task MarkQuizAsSeen(); void MarkQuizAsSeen();
} }
@@ -2,15 +2,15 @@ namespace NexusReader.UI.Shared.Services;
public interface IReaderInteractionService public interface IReaderInteractionService
{ {
event Func<string, Task>? OnNodeSelected; event Action<string>? OnNodeSelected;
event Func<string, Task>? OnScrollToBlockRequested; event Action<string>? OnScrollToBlockRequested;
event Func<string, Task>? OnHighlightBlockRequested; event Action<string>? OnHighlightBlockRequested;
event Func<string, string, SelectionCoordinates, Task>? OnTextSelected; event Action<string, string, SelectionCoordinates>? OnTextSelected;
Task NotifyNodeSelected(string nodeId); void NotifyNodeSelected(string nodeId);
Task RequestScrollToBlock(string blockId); void RequestScrollToBlock(string blockId);
Task RequestHighlightBlock(string blockId); void RequestHighlightBlock(string blockId);
Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords); void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
} }
public record SelectionCoordinates(double Top, double Left, double Width); public record SelectionCoordinates(double Top, double Left, double Width);
@@ -6,6 +6,6 @@ public interface ISyncService
{ {
Task<Result> InitializeAsync(); Task<Result> InitializeAsync();
Task<Result> UpdateProgressAsync(string pageId); Task<Result> UpdateProgressAsync(string pageId);
event Func<string, DateTime, Task> OnProgressReceived; event Action<string, DateTime> OnProgressReceived;
Task DisposeAsync(); Task DisposeAsync();
} }
@@ -3,6 +3,6 @@ namespace NexusReader.UI.Shared.Services;
public interface IThemeService public interface IThemeService
{ {
bool IsLightMode { get; } bool IsLightMode { get; }
event Func<Task>? OnThemeChanged; event Action? OnThemeChanged;
Task ToggleTheme(); void ToggleTheme();
} }
@@ -1,14 +1,12 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.User;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public interface IIdentityService public interface IIdentityService
{ {
Task<bool> RegisterAsync(string email, string password); Task<bool> RegisterAsync(string email, string password);
Task<bool> LoginAsync(string email, string password, bool rememberMe = false); Task<bool> LoginAsync(string email, string password);
Task LogoutAsync(); Task LogoutAsync();
Task<UserProfile?> GetProfileAsync(); Task<UserProfile?> GetProfileAsync();
Task<bool> RefreshTokenAsync(); Task<bool> RefreshTokenAsync();
@@ -16,32 +14,25 @@ public interface IIdentityService
public record UserProfile( public record UserProfile(
string Email, string Email,
int AITokenLimit,
int AITokensUsed, int AITokensUsed,
string CurrentPlan,
Guid TenantId, Guid TenantId,
SubscriptionPlanDto Plan,
int AverageQuizScore, int AverageQuizScore,
LastReadBookDto? LastReadBook) string LastReadBookTitle);
{
// Helper properties for UI compatibility
public string CurrentPlan => Plan?.Name ?? "Standard";
public int AITokenLimit => Plan?.AITokenLimit ?? 1000;
public string LastReadBookTitle => LastReadBook?.Title ?? "Brak aktywności";
}
public class IdentityService : IIdentityService public class IdentityService : IIdentityService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly INativeStorageService _storageService; private readonly INativeStorageService _storageService;
private readonly AuthenticationStateProvider? _authStateProvider; private readonly NexusAuthenticationStateProvider _authStateProvider;
private const string TokenKey = "nexus_auth_token"; private const string TokenKey = "nexus_auth_token";
private const string RefreshTokenKey = "nexus_refresh_token"; private const string RefreshTokenKey = "nexus_refresh_token";
private Task<UserProfile?>? _profileTask;
private UserProfile? _cachedProfile;
public IdentityService( public IdentityService(
HttpClient httpClient, HttpClient httpClient,
INativeStorageService storageService, INativeStorageService storageService,
AuthenticationStateProvider? authStateProvider = null) NexusAuthenticationStateProvider authStateProvider)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_storageService = storageService; _storageService = storageService;
@@ -54,23 +45,13 @@ public class IdentityService : IIdentityService
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false) public async Task<bool> LoginAsync(string email, string password)
{ {
var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password }); var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
_cachedProfile = null; // Clear cache to force fresh fetch var result = await response.Content.ReadFromJsonAsync<LoginResponse>();
LoginResponse? result = null;
try
{
result = await response.Content.ReadFromJsonAsync<LoginResponse>();
}
catch (System.Text.Json.JsonException)
{
// Expected if useCookies=true and body is empty
}
if (result != null && !string.IsNullOrEmpty(result.AccessToken)) if (result != null && !string.IsNullOrEmpty(result.AccessToken))
{ {
await _storageService.SaveSecureString(TokenKey, result.AccessToken); await _storageService.SaveSecureString(TokenKey, result.AccessToken);
@@ -78,25 +59,7 @@ public class IdentityService : IIdentityService
{ {
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken); await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
} }
} _authStateProvider.NotifyUserAuthentication(result.AccessToken);
// Always try to fetch profile after successful login (either via token or cookie)
var profile = await GetProfileAsync();
if (profile != null)
{
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
return true;
}
// If we have a successful status code but can't get the profile,
// we might still be logged in via cookie.
// We should try to notify with whatever info we have.
if (response.IsSuccessStatusCode)
{
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
return true; return true;
} }
} }
@@ -106,81 +69,21 @@ public class IdentityService : IIdentityService
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_cachedProfile = null; _storageService.RemoveSecure(TokenKey);
if (System.OperatingSystem.IsBrowser()) _storageService.RemoveSecure(RefreshTokenKey);
{ _authStateProvider.NotifyUserLogout();
await _storageService.SaveSecureString(TokenKey, "");
await _storageService.SaveSecureString(RefreshTokenKey, "");
await _storageService.SaveSecureString("nexus_user_email", "");
await _storageService.SaveSecureString("nexus_user_tenant", "");
}
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserLogout();
} }
public async Task<UserProfile?> GetProfileAsync() public async Task<UserProfile?> GetProfileAsync()
{ {
if (_cachedProfile != null)
{
return _cachedProfile;
}
if (_profileTask != null)
{
return await _profileTask;
}
_profileTask = GetProfileInternalAsync();
return await _profileTask;
}
private DateTime _lastFetchAttempt = DateTime.MinValue;
private async Task<UserProfile?> GetProfileInternalAsync()
{
if (!System.OperatingSystem.IsBrowser())
{
return null;
}
if (DateTime.UtcNow - _lastFetchAttempt < TimeSpan.FromSeconds(5))
{
return null;
}
_lastFetchAttempt = DateTime.UtcNow;
try try
{ {
var response = await _httpClient.GetAsync("identity/profile"); return await _httpClient.GetFromJsonAsync<UserProfile>("identity/profile");
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
await LogoutAsync();
return null;
}
if (response.IsSuccessStatusCode)
{
var profile = await response.Content.ReadFromJsonAsync<UserProfile>();
if (profile != null)
{
_cachedProfile = profile;
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
}
return profile;
}
return null;
} }
catch catch
{ {
return null; return null;
} }
finally
{
_profileTask = null;
}
} }
public async Task<bool> RefreshTokenAsync() public async Task<bool> RefreshTokenAsync()
@@ -202,15 +105,7 @@ public class IdentityService : IIdentityService
{ {
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken); await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
} }
_authStateProvider.NotifyUserAuthentication(loginResult.AccessToken);
var profile = await GetProfileAsync();
if (profile != null)
{
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
}
return true; return true;
} }
} }
@@ -36,10 +36,10 @@ public sealed partial class KnowledgeCoordinator : IDisposable
_interactionService.OnNodeSelected += HandleNodeSelected; _interactionService.OnNodeSelected += HandleNodeSelected;
} }
private async Task HandleNodeSelected(string nodeId) private void HandleNodeSelected(string nodeId)
{ {
await _interactionService.RequestScrollToBlock(nodeId); _interactionService.RequestScrollToBlock(nodeId);
await _interactionService.RequestHighlightBlock(nodeId); _interactionService.RequestHighlightBlock(nodeId);
} }
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global") public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global")
@@ -48,8 +48,8 @@ public sealed partial class KnowledgeCoordinator : IDisposable
LogGeneratingGraph(tenantId); LogGeneratingGraph(tenantId);
await _graphService.Clear(); _graphService.Clear();
await _graphService.SetLoading(true); _graphService.SetLoading(true);
try try
{ {
@@ -59,7 +59,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
var packet = result.Value; var packet = result.Value;
if (packet.Graph != null) if (packet.Graph != null)
{ {
await _graphService.UpdateGraph(packet.Graph); _graphService.UpdateGraph(packet.Graph);
OnGraphUpdated?.Invoke(packet.Graph); OnGraphUpdated?.Invoke(packet.Graph);
await _platformService.VibrateSuccessAsync(); await _platformService.VibrateSuccessAsync();
} }
@@ -71,15 +71,15 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
} }
public async Task OnBlockReachedAsync(string blockId, string content) public void OnBlockReached(string blockId, string content)
{ {
// Only update active node for "TU JESTEŚ" logic, do NOT trigger highlight here // Only update active node for "TU JESTEŚ" logic, do NOT trigger highlight here
await _graphService.SetActiveNode(blockId); _graphService.SetActiveNode(blockId);
} }
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global") public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{ {
await _quizService.SetHydrating(true); _quizService.SetHydrating(true);
LogRequestingSummary(tenantId); LogRequestingSummary(tenantId);
try try
{ {
@@ -91,7 +91,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex)) .Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
.ToList(); .ToList();
await _quizService.SetQuiz(null, new QuizDto(quizQuestions)); _quizService.SetQuiz(null, new QuizDto(quizQuestions));
await _platformService.VibrateSuccessAsync(); await _platformService.VibrateSuccessAsync();
return packet; return packet;
} }
@@ -104,15 +104,15 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
finally finally
{ {
await _quizService.SetHydrating(false); _quizService.SetHydrating(false);
} }
return null; return null;
} }
public async Task ClearAsync() public void Clear()
{ {
await _graphService.Clear(); _graphService.Clear();
await _quizService.SetQuiz(null, null); _quizService.SetQuiz(null, null);
} }
public void Dispose() public void Dispose()
@@ -8,36 +8,36 @@ public sealed class KnowledgeGraphService : IKnowledgeGraphService
public string? ActiveNodeId { get; private set; } public string? ActiveNodeId { get; private set; }
public bool IsLoading { get; private set; } public bool IsLoading { get; private set; }
public event Func<Task>? OnGraphUpdated; public event Action? OnGraphUpdated;
public event Func<string, Task>? OnActiveNodeChanged; public event Action<string>? OnActiveNodeChanged;
public event Func<bool, Task>? OnLoadingChanged; public event Action<bool>? OnLoadingChanged;
public async Task UpdateGraph(GraphDataDto newData) public void UpdateGraph(GraphDataDto newData)
{ {
CurrentGraphData = newData; CurrentGraphData = newData;
IsLoading = false; IsLoading = false;
if (OnLoadingChanged != null) await OnLoadingChanged(false); OnLoadingChanged?.Invoke(false);
if (OnGraphUpdated != null) await OnGraphUpdated(); OnGraphUpdated?.Invoke();
} }
public async Task SetActiveNode(string nodeId) public void SetActiveNode(string nodeId)
{ {
if (ActiveNodeId == nodeId) return; if (ActiveNodeId == nodeId) return;
ActiveNodeId = nodeId; ActiveNodeId = nodeId;
if (OnActiveNodeChanged != null) await OnActiveNodeChanged(nodeId); OnActiveNodeChanged?.Invoke(nodeId);
} }
public async Task SetLoading(bool isLoading) public void SetLoading(bool isLoading)
{ {
IsLoading = isLoading; IsLoading = isLoading;
if (OnLoadingChanged != null) await OnLoadingChanged(isLoading); OnLoadingChanged?.Invoke(isLoading);
} }
public async Task Clear() public void Clear()
{ {
CurrentGraphData = null; CurrentGraphData = null;
ActiveNodeId = null; ActiveNodeId = null;
IsLoading = false; IsLoading = false;
if (OnGraphUpdated != null) await OnGraphUpdated(); OnGraphUpdated?.Invoke();
} }
} }
@@ -15,44 +15,22 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
_storageService = storageService; _storageService = storageService;
} }
private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
try try
{ {
if (_cachedState != null) return _cachedState; var result = await _storageService.GetSecureString(TokenKey);
var token = result.IsSuccess ? result.Value : null;
var tokenResult = await _storageService.GetSecureString(TokenKey); if (string.IsNullOrWhiteSpace(token))
var token = tokenResult.IsSuccess ? tokenResult.Value : null;
// 1. Try Token-based auth
if (!string.IsNullOrWhiteSpace(token))
{ {
var emailResult = await _storageService.GetSecureString("nexus_user_email"); return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
{
_cachedState = CreateState(emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer");
return _cachedState;
}
} }
// 2. Try Cookie-based auth indicators var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
var storedEmailResult = await _storageService.GetSecureString("nexus_user_email"); var user = new ClaimsPrincipal(identity);
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
{
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
_cachedState = CreateState(storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth");
return _cachedState;
}
// 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login). return new AuthenticationState(user);
// We should return anonymous for now but trigger a background check if we're in WASM.
// Wait! In WASM, the first GetAuthenticationStateAsync is awaited.
// We can do a quick check here if it's the first time.
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
} }
catch (Exception) catch (Exception)
{ {
@@ -60,28 +38,44 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
} }
} }
private AuthenticationState CreateState(string email, string tenantId, string authType) public void NotifyUserAuthentication(string token)
{ {
var claims = new List<Claim> var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
{ var user = new ClaimsPrincipal(identity);
new Claim(ClaimTypes.Name, email), var authState = Task.FromResult(new AuthenticationState(user));
new Claim(ClaimTypes.Email, email), NotifyAuthenticationStateChanged(authState);
new Claim("TenantId", tenantId)
};
var identity = new ClaimsIdentity(claims, authType);
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void NotifyUserAuthentication(string email, string tenantId)
{
_cachedState = CreateState(email, tenantId, "OpaqueBearer");
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
} }
public void NotifyUserLogout() public void NotifyUserLogout()
{ {
_cachedState = null;
var guest = new ClaimsPrincipal(new ClaimsIdentity()); var guest = new ClaimsPrincipal(new ClaimsIdentity());
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest))); var authState = Task.FromResult(new AuthenticationState(guest));
NotifyAuthenticationStateChanged(authState);
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var claims = new List<Claim>();
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
if (keyValuePairs != null)
{
claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString() ?? string.Empty)));
}
return claims;
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
} }
} }
@@ -9,34 +9,34 @@ public sealed class QuizStateService : IQuizStateService
public bool IsHydrating { get; private set; } public bool IsHydrating { get; private set; }
public bool HasNewQuiz { get; private set; } public bool HasNewQuiz { get; private set; }
public event Func<string, Task>? OnQuizRequested; public event Action<string>? OnQuizRequested;
public event Func<Task>? OnQuizUpdated; public event Action? OnQuizUpdated;
public async Task RequestQuiz(string blockId) public void RequestQuiz(string blockId)
{ {
CurrentQuizBlockId = blockId; CurrentQuizBlockId = blockId;
if (OnQuizRequested != null) await OnQuizRequested(blockId); OnQuizRequested?.Invoke(blockId);
} }
public async Task SetQuiz(string? blockId, QuizDto? quiz) public void SetQuiz(string? blockId, QuizDto quiz)
{ {
CurrentQuizBlockId = blockId; CurrentQuizBlockId = blockId;
CurrentQuiz = quiz; CurrentQuiz = quiz;
IsHydrating = false; IsHydrating = false;
HasNewQuiz = quiz != null; HasNewQuiz = true;
if (OnQuizUpdated != null) await OnQuizUpdated(); OnQuizUpdated?.Invoke();
} }
public async Task SetHydrating(bool hydrating) public void SetHydrating(bool hydrating)
{ {
IsHydrating = hydrating; IsHydrating = hydrating;
if (OnQuizUpdated != null) await OnQuizUpdated(); OnQuizUpdated?.Invoke();
} }
public async Task MarkQuizAsSeen() public void MarkQuizAsSeen()
{ {
if (!HasNewQuiz) return; if (!HasNewQuiz) return;
HasNewQuiz = false; HasNewQuiz = false;
if (OnQuizUpdated != null) await OnQuizUpdated(); OnQuizUpdated?.Invoke();
} }
} }
@@ -2,28 +2,28 @@ namespace NexusReader.UI.Shared.Services;
public sealed class ReaderInteractionService : IReaderInteractionService public sealed class ReaderInteractionService : IReaderInteractionService
{ {
public event Func<string, Task>? OnNodeSelected; public event Action<string>? OnNodeSelected;
public event Func<string, Task>? OnScrollToBlockRequested; public event Action<string>? OnScrollToBlockRequested;
public event Func<string, Task>? OnHighlightBlockRequested; public event Action<string>? OnHighlightBlockRequested;
public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected; public event Action<string, string, SelectionCoordinates>? OnTextSelected;
public async Task NotifyNodeSelected(string nodeId) public void NotifyNodeSelected(string nodeId)
{ {
if (OnNodeSelected != null) await OnNodeSelected(nodeId); OnNodeSelected?.Invoke(nodeId);
} }
public async Task RequestScrollToBlock(string blockId) public void RequestScrollToBlock(string blockId)
{ {
if (OnScrollToBlockRequested != null) await OnScrollToBlockRequested(blockId); OnScrollToBlockRequested?.Invoke(blockId);
} }
public async Task RequestHighlightBlock(string blockId) public void RequestHighlightBlock(string blockId)
{ {
if (OnHighlightBlockRequested != null) await OnHighlightBlockRequested(blockId); OnHighlightBlockRequested?.Invoke(blockId);
} }
public async Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords) public void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords)
{ {
if (OnTextSelected != null) await OnTextSelected(text, blockId, coords); OnTextSelected?.Invoke(text, blockId, coords);
} }
} }
@@ -14,7 +14,7 @@ public class SyncService : ISyncService, IAsyncDisposable
private bool _isInitialized; private bool _isInitialized;
private CancellationTokenSource? _debounceCts; private CancellationTokenSource? _debounceCts;
public event Func<string, DateTime, Task>? OnProgressReceived; public event Action<string, DateTime>? OnProgressReceived;
public SyncService( public SyncService(
HttpClient httpClient, HttpClient httpClient,
@@ -44,9 +44,9 @@ public class SyncService : ISyncService, IAsyncDisposable
.WithAutomaticReconnect() .WithAutomaticReconnect()
.Build(); .Build();
_hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) => _hubConnection.On<string, DateTime>("ProgressUpdated", (pageId, timestamp) =>
{ {
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp); OnProgressReceived?.Invoke(pageId, timestamp);
}); });
try try
@@ -3,11 +3,11 @@ namespace NexusReader.UI.Shared.Services;
public sealed class ThemeService : IThemeService public sealed class ThemeService : IThemeService
{ {
public bool IsLightMode { get; private set; } = false; public bool IsLightMode { get; private set; } = false;
public event Func<Task>? OnThemeChanged; public event Action? OnThemeChanged;
public async Task ToggleTheme() public void ToggleTheme()
{ {
IsLightMode = !IsLightMode; IsLightMode = !IsLightMode;
if (OnThemeChanged != null) await OnThemeChanged(); OnThemeChanged?.Invoke();
} }
} }
@@ -74,28 +74,6 @@
.toggle-visibility { position: absolute; right: 16px; background: none; border: none; color: var(--nexus-text-muted); cursor: pointer; padding: 4px; z-index: 5; } .toggle-visibility { position: absolute; right: 16px; background: none; border: none; color: var(--nexus-text-muted); cursor: pointer; padding: 4px; z-index: 5; }
.auth-options {
display: flex;
justify-content: flex-start;
padding: 0 4px;
}
.remember-me {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.85rem;
color: var(--nexus-text-muted);
user-select: none;
}
.remember-me input {
accent-color: var(--nexus-primary);
width: 14px;
height: 14px;
}
.btn-submit-auth { .btn-submit-auth {
width: 100%; padding: 14px; background: var(--nexus-primary); border: none; border-radius: 12px; width: 100%; padding: 14px; background: var(--nexus-primary); border: none; border-radius: 12px;
color: #000; font-size: 0.95rem; font-weight: 700; cursor: pointer; transition: all 0.2s; color: #000; font-size: 0.95rem; font-weight: 700; cursor: pointer; transition: all 0.2s;
@@ -1,13 +1,10 @@
import * as d3 from 'https://esm.sh/d3@7'; import * as d3 from 'https://esm.sh/d3@7';
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
let simulation; let simulation;
let zoomBehavior; let zoomBehavior;
let svgElement; let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver; let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler;
export function mount(containerId, data, dotNetHelper) { export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
@@ -21,8 +18,7 @@ export function mount(containerId, data, dotNetHelper) {
svgElement = d3.select(container).append("svg") svgElement = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height]) .attr("viewBox", [0, 0, width, height])
.attr("width", "100%") .attr("width", "100%")
.attr("height", "100%") .attr("height", "100%");
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
// Radial gradient for Nebula effect // Radial gradient for Nebula effect
const defs = svgElement.append("defs"); const defs = svgElement.append("defs");
@@ -70,19 +66,14 @@ export function mount(containerId, data, dotNetHelper) {
svgElement.call(zoomBehavior).on("wheel.zoom", null); svgElement.call(zoomBehavior).on("wheel.zoom", null);
// Use ResizeObserver for more reliable container size tracking resizeHandler = () => handleResize(containerId);
resizeObserver = new ResizeObserver(entries => { window.addEventListener('resize', resizeHandler);
for (let entry of entries) {
handleResize(containerId);
}
});
resizeObserver.observe(container);
simulation = d3.forceSimulation() simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120)) .force("link", d3.forceLink().id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400)) .force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2)) .force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20)); .force("collide", d3.forceCollide().radius(50));
simulation.on("tick", () => { simulation.on("tick", () => {
if (link) { if (link) {
@@ -95,14 +86,7 @@ export function mount(containerId, data, dotNetHelper) {
} }
if (node) { if (node) {
node.attr("transform", d => { node.attr("transform", d => `translate(${d.x},${d.y})`);
// Keep within bounds with padding
const pillWidth = getPillWidth(d);
const halfWidth = pillWidth / 2;
d.x = Math.max(halfWidth + 20, Math.min(width - halfWidth - 20, d.x));
d.y = Math.max(35, Math.min(height - 35, d.y));
return `translate(${d.x},${d.y})`;
});
} }
if (badge && badge.style("display") !== "none") { if (badge && badge.style("display") !== "none") {
@@ -150,10 +134,9 @@ export function updateData(data) {
.attr("fill", "none") .attr("fill", "none")
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1) .attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0") .attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
.style("opacity", 0) .call(e => e.transition().duration(500).attr("opacity", 1)),
.call(enter => enter.transition().duration(500).style("opacity", 1)),
update => update, update => update,
exit => exit.transition().duration(500).style("opacity", 0).remove() exit => exit.remove()
); );
// Update Nodes // Update Nodes
@@ -163,9 +146,8 @@ export function updateData(data) {
.join( .join(
enter => { enter => {
const g = enter.append("g") const g = enter.append("g")
.attr("class", "node-group neon-flash-node") .attr("class", "node-group")
.style("cursor", "pointer") .style("cursor", "pointer")
.style("opacity", 0)
.on("click", (e, d) => { .on("click", (e, d) => {
currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id); currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
setActiveNode(d.id); setActiveNode(d.id);
@@ -180,15 +162,16 @@ export function updateData(data) {
if (d.type === 'Rule') return '#ff4444'; if (d.type === 'Rule') return '#ff4444';
return "url(#nebulaGlow)"; return "url(#nebulaGlow)";
}) })
.attr("opacity", d => d.group === 'current' ? 0.6 : 0.2); .attr("opacity", 0)
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
g.append("rect") g.append("rect")
.attr("class", "node-pill") .attr("class", "node-pill")
.attr("x", d => -getPillWidth(d) / 2) .attr("x", d => -(d.label.length * 4 + 10))
.attr("y", -15) .attr("y", -12)
.attr("width", d => getPillWidth(d)) .attr("width", d => d.label.length * 8 + 20)
.attr("height", 30) .attr("height", 24)
.attr("rx", 15) .attr("rx", 12)
.attr("fill", "rgba(20, 20, 20, 0.9)") .attr("fill", "rgba(20, 20, 20, 0.9)")
.attr("stroke", d => { .attr("stroke", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)'; if (d.type === 'Definition') return 'var(--nexus-accent)';
@@ -198,29 +181,21 @@ export function updateData(data) {
.attr("stroke-width", 1); .attr("stroke-width", 1);
g.append("text") g.append("text")
.text(d => getDisplayLabel(d)) .text(d => d.label)
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.attr("y", 5) .attr("y", 4)
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc') .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
.attr("font-size", "0.8rem"); .attr("font-size", "0.8rem");
g.append("title")
.text(d => d.label);
g.transition().duration(500).style("opacity", 1);
return g; return g;
}, },
update => update.classed("neon-flash-node", false), update => update,
exit => exit.transition().duration(500).style("opacity", 0).remove() exit => exit.remove()
); );
simulation.nodes(data.nodes); simulation.nodes(data.nodes);
simulation.force("link").links(data.links); simulation.force("link").links(data.links);
simulation.alpha(0.5).restart(); simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
setTimeout(zoomToFit, 100);
} }
function drag(simulation) { function drag(simulation) {
@@ -247,59 +222,32 @@ function drag(simulation) {
export function setActiveNode(nodeId) { export function setActiveNode(nodeId) {
if (!svgElement || !node) return; if (!svgElement || !node) return;
// Safety check: ensure we only target the first occurrence if IDs are duplicated
const targetNode = node.filter(d => d.id === nodeId); const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) { if (targetNode.empty()) return;
dimNodes(null);
badge.style("display", "none");
return;
}
const firstMatch = targetNode.filter((d, i) => i === 0); const d = targetNode.datum();
const d = firstMatch.datum();
// Reset all active classes // Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false); rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
firstMatch.select(".node-pill").classed("nexus-node-active", true); targetNode.select(".node-pill").classed("nexus-node-active", true);
// Position badge // Position badge
badge.style("display", "block").datum(d); badge.style("display", "block").datum(d);
badge.attr("transform", `translate(${d.x},${d.y})`); badge.attr("transform", `translate(${d.x},${d.y})`);
// Dim others (only exact matches for nodeId will be fully opaque) // Smooth transition
dimNodes(nodeId);
// Smooth transition to the first matching node
svgElement.transition().duration(1000).call( svgElement.transition().duration(1000).call(
zoomBehavior.transform, zoomBehavior.transform,
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y) d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
); );
} }
export function dimNodes(activeNodeId) {
if (!node) return;
node.transition().duration(500)
.style("opacity", d => (activeNodeId === null || d.id === activeNodeId) ? 1 : 0.4);
if (link) {
link.transition().duration(500)
.style("opacity", d => {
if (activeNodeId === null) return 1;
// Check if this link is connected to the active node
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
return (sourceId === activeNodeId || targetId === activeNodeId) ? 1 : 0.1;
});
}
}
export function unmount(containerId) { export function unmount(containerId) {
if (simulation) { if (simulation) {
simulation.stop(); simulation.stop();
} }
if (resizeObserver) { if (resizeHandler) {
resizeObserver.disconnect(); window.removeEventListener('resize', resizeHandler);
} }
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (container) { if (container) {
@@ -345,43 +293,9 @@ export function zoomOut() {
} }
export function zoomReset() { export function zoomReset() {
zoomToFit(); if (svgElement && zoomBehavior) {
} svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity);
}
export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
// Get the actual bounding box of the nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
node.each(d => {
const pw = getPillWidth(d) / 2;
minX = Math.min(minX, d.x - pw);
maxX = Math.max(maxX, d.x + pw);
minY = Math.min(minY, d.y - 15);
maxY = Math.max(maxY, d.y + 15);
});
if (minX === Infinity) return;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const padding = 60;
const scale = Math.min(
(width - padding) / graphWidth,
(height - padding) / graphHeight,
1.2 // Max scale
);
svgElement.transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-midX, -midY)
);
} }
export function clear() { export function clear() {
@@ -1,31 +0,0 @@
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Client.Handlers;
public class AuthenticationHeaderHandler : DelegatingHandler
{
private readonly INativeStorageService _storageService;
private const string TokenKey = "nexus_auth_token";
public AuthenticationHeaderHandler(INativeStorageService storageService)
{
_storageService = storageService;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Ensure cookies are sent (needed for InteractiveAuto SSR synchronization)
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
var tokenResult = await _storageService.GetSecureString(TokenKey);
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.Value);
}
return await base.SendAsync(request, cancellationToken);
}
}
@@ -12,7 +12,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="12.1.1" /> <PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+1 -28
View File
@@ -4,9 +4,6 @@ using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services; using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
using NexusReader.Application; using NexusReader.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using NexusReader.Data.Persistence;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -33,33 +30,9 @@ builder.Services.AddCascadingAuthenticationState();
// AI & Content Services // AI & Content Services
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>(); builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
}).AddHttpMessageHandler<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
// Dummy registrations for server-only handlers to satisfy DI validation
builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbContextFactory());
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddScoped<IEpubService, WasmEpubService>(); builder.Services.AddScoped<IEpubService, WasmEpubService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
public class ThrowingDbContextFactory : IDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client.");
}
public class ThrowingEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
public void Dispose() { }
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Embedding generation cannot be used in WASM client.");
public object? GetService(Type serviceType, object? serviceKey = null) => null;
}
+7 -56
View File
@@ -6,7 +6,7 @@ using NexusReader.Application.DTOs.User;
using NexusReader.Web.Client.Services; using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Data.Persistence; using NexusReader.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -53,6 +53,8 @@ builder.Services.AddHttpClient("NexusAPI", client =>
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>(); builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication(); builder.Services.AddApplication();
@@ -91,31 +93,9 @@ builder.Services.AddIdentityApiEndpoints<NexusUser>()
builder.Services.ConfigureApplicationCookie(options => builder.Services.ConfigureApplicationCookie(options =>
{ {
options.LoginPath = "/account/login"; options.LoginPath = "/account/login";
options.LogoutPath = "/account/logout";
options.AccessDeniedPath = "/account/access-denied";
options.Cookie.Name = "NexusReader.Auth";
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromDays(30); options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true; options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context =>
{
var isApiRequest = context.Request.Path.StartsWithSegments("/api") ||
context.Request.Path.StartsWithSegments("/identity") ||
context.Request.Headers["Accept"].ToString().Contains("application/json");
if (isApiRequest)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
}
else
{
context.Response.Redirect(context.RedirectUri);
}
return Task.CompletedTask;
};
}); });
builder.Services.Configure<IdentityOptions>(options => builder.Services.Configure<IdentityOptions>(options =>
@@ -155,7 +135,7 @@ using (var scope = app.Services.CreateScope())
{ {
var services = scope.ServiceProvider; var services = scope.ServiceProvider;
var logger = services.GetRequiredService<ILogger<Program>>(); var logger = services.GetRequiredService<ILogger<Program>>();
var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Data.Persistence.AppDbContext>>(); var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Infrastructure.Persistence.AppDbContext>>();
using var dbContext = await dbContextFactory.CreateDbContextAsync(); using var dbContext = await dbContextFactory.CreateDbContextAsync();
int maxRetries = 5; int maxRetries = 5;
@@ -375,57 +355,31 @@ app.MapGet("/identity/login/google", (string? returnUrl) =>
app.MapGet("/identity/callback/google", async ( app.MapGet("/identity/callback/google", async (
HttpContext context, HttpContext context,
SignInManager<NexusUser> signInManager, SignInManager<NexusUser> signInManager,
UserManager<NexusUser> userManager, UserManager<NexusUser> userManager) =>
ILogger<Program> logger) =>
{ {
var info = await signInManager.GetExternalLoginInfoAsync(); var info = await signInManager.GetExternalLoginInfoAsync();
if (info == null) if (info == null) return Results.Redirect("/account/login?error=ExternalLoginFailed");
{
logger.LogWarning("External login info from Google is null.");
return Results.Redirect("/account/login?error=ExternalLoginFailed");
}
var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
if (result.Succeeded) if (result.Succeeded)
{ {
logger.LogInformation("User logged in via Google: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email));
return Results.Redirect("/"); return Results.Redirect("/");
} }
if (result.IsLockedOut)
{
logger.LogWarning("User account locked out during Google login: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email));
return Results.Redirect("/account/login?error=LockedOut");
}
// New user provisioning // New user provisioning
var email = info.Principal.FindFirstValue(ClaimTypes.Email); var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null) if (email != null)
{ {
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true }; var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
var createResult = await userManager.CreateAsync(user); var createResult = await userManager.CreateAsync(user);
if (createResult.Succeeded) if (createResult.Succeeded)
{ {
await userManager.AddLoginAsync(user, info); await userManager.AddLoginAsync(user, info);
await signInManager.SignInAsync(user, isPersistent: false); await signInManager.SignInAsync(user, isPersistent: false);
logger.LogInformation("New user provisioned via Google: {Email}", email);
return Results.Redirect("/"); return Results.Redirect("/");
} }
// Log specific errors
foreach (var error in createResult.Errors)
{
logger.LogError("Google provisioning failed for {Email}: {Code} - {Description}", email, error.Code, error.Description);
}
if (createResult.Errors.Any(e => e.Code == "DuplicateEmail" || e.Code == "DuplicateUserName"))
{
return Results.Redirect("/account/login?error=UserAlreadyExists");
}
} }
logger.LogError("Google provisioning failed - unknown reason for email {Email}", email);
return Results.Redirect("/account/login?error=ProvisioningFailed"); return Results.Redirect("/account/login?error=ProvisioningFailed");
}); });
@@ -442,7 +396,6 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUs
{ {
Email = u.Email ?? string.Empty, Email = u.Email ?? string.Empty,
AITokensUsed = u.AITokensUsed, AITokensUsed = u.AITokensUsed,
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{ {
Id = u.SubscriptionPlan.Id, Id = u.SubscriptionPlan.Id,
@@ -450,9 +403,7 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUs
AITokenLimit = u.SubscriptionPlan.AITokenLimit, AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(), } : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0) AverageQuizScore = u.QuizResults.Any() ? (int)u.QuizResults.Average(q => q.Percentage) : 0,
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
: 0,
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{ {
Id = e.Id, Id = e.Id,