Compare commits
5 Commits
main
..
0e0af40f5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e0af40f5e | |||
| 55cc3ae10d | |||
| 775fb73fa9 | |||
| 140cf270cc | |||
| 2248a2b757 |
@@ -29,9 +29,12 @@ This skill defines the architectural guardrails for the NexusReader project to e
|
||||
- Use `FluentResults` (`Result<T>`) for all Application services and handlers.
|
||||
- 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>`.
|
||||
+
|
||||
+### 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
|
||||
- [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports.
|
||||
|
||||
@@ -8,4 +8,7 @@ description: D3.js standards for Knowledge Graph
|
||||
- **JS Interop:** Use ES6 modules and `IJSObjectReference`.
|
||||
- **Responsiveness:** SVG must use `viewBox` for fluid portrait scaling.
|
||||
- **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`.
|
||||
@@ -22,7 +22,7 @@ description: Design System & Component rules for Blazor
|
||||
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
|
||||
- **Typography:**
|
||||
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
|
||||
- Reading Content: `Merriweather` (Serif) for books and articles to ensure high readability.
|
||||
- Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
|
||||
- **Effects:**
|
||||
- Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`).
|
||||
- Glassmorphism for overlays and modals.
|
||||
@@ -30,6 +30,11 @@ description: Design System & Component rules for Blazor
|
||||
- **Adaptive Layouts:**
|
||||
- Support `.platform-mobile` and `.platform-desktop` context classes.
|
||||
- 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):**
|
||||
- Touch Targets: Min `44x44px` on mobile (enforced via CSS variables).
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.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.Data/NexusReader.Data.csproj" />
|
||||
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/NexusReader.Web.New/">
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -5,5 +5,6 @@ public record SubscriptionPlanDto
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public int AITokenLimit { get; init; }
|
||||
public bool IsUnlimitedTokens { get; init; }
|
||||
public decimal MonthlyPrice { get; init; }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddMapsterConfiguration();
|
||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Data\NexusReader.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -13,7 +14,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.1" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -4,7 +4,8 @@ using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
using NexusReader.Data.Persistence;
|
||||
using Pgvector;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
@@ -16,14 +17,14 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId,
|
||||
|
||||
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
|
||||
{
|
||||
private readonly IApplicationDbContext _dbContext;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
|
||||
public SearchLibrarySemanticallyQueryHandler(
|
||||
IApplicationDbContext dbContext,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
return Result.Fail("Query text cannot be empty.");
|
||||
}
|
||||
|
||||
using var dbContext = _dbContextFactory.CreateDbContext();
|
||||
try
|
||||
{
|
||||
// 1. Generate embedding for user query
|
||||
@@ -41,7 +43,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
|
||||
|
||||
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||
var candidates = await _dbContext.KnowledgeUnits
|
||||
var candidates = await dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
@@ -51,7 +53,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
if (!candidates.Any())
|
||||
{
|
||||
// Fallback to legacy cache if no granular units found
|
||||
var legacyResults = await _dbContext.SemanticKnowledgeCache
|
||||
var legacyResults = await dbContext.SemanticKnowledgeCache
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
@@ -68,13 +70,13 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
|
||||
// 3. Graph Expansion: Pull related units (e.g. Definitions, Next steps)
|
||||
var candidateIds = candidates.Select(c => c.Id).ToList();
|
||||
var links = await _dbContext.KnowledgeUnitLinks
|
||||
var links = await dbContext.KnowledgeUnitLinks
|
||||
.AsNoTracking()
|
||||
.Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next"))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList();
|
||||
var relatedUnits = await _dbContext.KnowledgeUnits
|
||||
var relatedUnits = await dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(u => relatedIds.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||
|
||||
@@ -2,16 +2,19 @@ using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NexusReader.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Security.Authorization;
|
||||
|
||||
public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
|
||||
{
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public ProUserHandler(UserManager<NexusUser> userManager)
|
||||
public ProUserHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
@@ -24,14 +27,18 @@ public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var user = await db.Users
|
||||
.Include(u => u.SubscriptionPlan)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Rule 1: Explicit Pro plan
|
||||
if (user.SubscriptionPlanId == SubscriptionPlan.ProId)
|
||||
// Rule 1: Unlimited access
|
||||
if (user.SubscriptionPlan?.IsUnlimitedTokens == true)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
|
||||
+2
-2
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260428184727_InitialPostgres")]
|
||||
+1
-1
@@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialPostgres : Migration
|
||||
+2
-2
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260428185239_IncreaseHashLength")]
|
||||
+1
-1
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IncreaseHashLength : Migration
|
||||
+2
-2
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260429080302_AddQuizResults")]
|
||||
+1
-1
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuizResults : Migration
|
||||
+2
-2
@@ -4,13 +4,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")]
|
||||
+1
-1
@@ -7,7 +7,7 @@ using Pgvector;
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FinalNormalizedSubscriptionArchitecture : Migration
|
||||
Generated
+659
@@ -0,0 +1,659 @@
|
||||
// <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
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -3,13 +3,13 @@ using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
@@ -200,7 +200,7 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
b.ToTable("Ebooks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
@@ -246,7 +246,7 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
b.ToTable("KnowledgeUnits", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
@@ -278,7 +278,7 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
b.ToTable("KnowledgeUnitLinks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
@@ -413,7 +413,7 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
b.ToTable("QuizResults", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
@@ -458,7 +458,7 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
b.ToTable("SemanticKnowledgeCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
@@ -472,6 +472,9 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
@@ -490,21 +493,23 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
b.ToTable("SubscriptionPlans", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 1000,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = ""
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
@@ -513,6 +518,7 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
@@ -520,7 +526,8 @@ namespace NexusReader.Infrastructure.Migrations
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 500000,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
+16
-9
@@ -1,16 +1,23 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
{
|
||||
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<KnowledgeUnit> KnowledgeUnits => Set<KnowledgeUnit>();
|
||||
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
|
||||
@@ -20,6 +27,8 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
|
||||
modelBuilder.Entity<NexusUser>(entity =>
|
||||
@@ -38,8 +47,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
||||
.HasDefaultValue(1);
|
||||
});
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||
{
|
||||
entity.HasIndex(p => p.PlanName).IsUnique();
|
||||
@@ -97,10 +104,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
||||
|
||||
// Seed Subscription Plans with deterministic IDs
|
||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 1000, MonthlyPrice = 0m, StripeProductId = "" },
|
||||
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, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" },
|
||||
new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" }
|
||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
||||
new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, IsUnlimitedTokens = false, 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 = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" }
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
+25
-21
@@ -7,16 +7,16 @@ using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
public static class DbInitializer
|
||||
{
|
||||
public static async Task SeedAsync(IServiceProvider serviceProvider)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<NexusUser>>();
|
||||
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
||||
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -27,9 +27,9 @@ public static class DbInitializer
|
||||
{
|
||||
dbContext.SubscriptionPlans.AddRange(new List<SubscriptionPlan>
|
||||
{
|
||||
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, MonthlyPrice = 19, StripeProductId = "prod_Pro123" },
|
||||
new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" }
|
||||
new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, 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.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" }
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
Console.WriteLine("[Seeder] Subscription plans seeded.");
|
||||
@@ -39,43 +39,47 @@ public static class DbInitializer
|
||||
string[] roleNames = { "Admin", "User" };
|
||||
foreach (var roleName in roleNames)
|
||||
{
|
||||
var roleExist = await roleManager.RoleExistsAsync(roleName);
|
||||
var roleExist = dbContext.Roles.Any(r => r.Name == roleName);
|
||||
if (!roleExist)
|
||||
{
|
||||
await roleManager.CreateAsync(new IdentityRole(roleName));
|
||||
dbContext.Roles.Add(new IdentityRole { Name = roleName, NormalizedName = roleName.ToUpper() });
|
||||
Console.WriteLine($"[Seeder] Created role: {roleName}");
|
||||
}
|
||||
}
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Seed Admin User
|
||||
var adminEmail = "admin@nexus.com";
|
||||
var adminUser = await userManager.FindByEmailAsync(adminEmail);
|
||||
var normalizedEmail = adminEmail.ToUpper();
|
||||
var adminUser = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
|
||||
|
||||
if (adminUser == null)
|
||||
{
|
||||
adminUser = new NexusUser
|
||||
{
|
||||
UserName = adminEmail,
|
||||
NormalizedUserName = normalizedEmail,
|
||||
Email = adminEmail,
|
||||
NormalizedEmail = normalizedEmail,
|
||||
EmailConfirmed = true,
|
||||
SubscriptionPlanId = SubscriptionPlan.EnterpriseId,
|
||||
AITokenLimit = 1000000,
|
||||
TenantId = Guid.NewGuid().ToString()
|
||||
TenantId = Guid.NewGuid().ToString(),
|
||||
SecurityStamp = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!");
|
||||
if (createPowerUser.Succeeded)
|
||||
{
|
||||
await userManager.AddToRoleAsync(adminUser, "Admin");
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!");
|
||||
|
||||
dbContext.Users.Add(adminUser);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var adminRole = await dbContext.Roles.FirstAsync(r => r.Name == "Admin");
|
||||
dbContext.UserRoles.Add(new IdentityUserRole<string> { UserId = adminUser.Id, RoleId = adminRole.Id });
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var errors = string.Join(", ", createPowerUser.Errors.Select(e => e.Description));
|
||||
Console.WriteLine($"[Seeder] Failed to create admin user: {errors}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[Seeder] Admin user already exists.");
|
||||
}
|
||||
@@ -23,6 +23,8 @@ public class SubscriptionPlan
|
||||
|
||||
public int AITokenLimit { get; set; }
|
||||
|
||||
public bool IsUnlimitedTokens { get; set; }
|
||||
|
||||
public decimal MonthlyPrice { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
|
||||
@@ -5,7 +5,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using GeminiDotnet;
|
||||
using GeminiDotnet.Extensions.AI;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
|
||||
@@ -4,27 +4,28 @@ using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
|
||||
namespace NexusReader.Infrastructure.Handlers;
|
||||
|
||||
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IHubContext<SyncHub> _hubContext;
|
||||
|
||||
public UpdateReadingProgressCommandHandler(
|
||||
AppDbContext context,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IHubContext<SyncHub> hubContext)
|
||||
{
|
||||
_context = context;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
@@ -34,7 +35,7 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices
|
||||
await _hubContext.Clients
|
||||
|
||||
@@ -2,7 +2,8 @@ using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Infrastructure.Identity;
|
||||
|
||||
@@ -11,12 +12,12 @@ namespace NexusReader.Infrastructure.Identity;
|
||||
/// </summary>
|
||||
public class TokenLimitHandler : AuthorizationHandler<TokenLimitRequirement>
|
||||
{
|
||||
private readonly AppDbContext _dbContext;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
|
||||
public TokenLimitHandler(AppDbContext dbContext, UserManager<NexusUser> userManager)
|
||||
public TokenLimitHandler(IDbContextFactory<AppDbContext> dbContextFactory, UserManager<NexusUser> userManager)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
@@ -30,14 +31,18 @@ public class TokenLimitHandler : AuthorizationHandler<TokenLimitRequirement>
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var user = await db.Users
|
||||
.Include(u => u.SubscriptionPlan)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has available tokens
|
||||
if (user.AITokensUsed < user.AITokenLimit)
|
||||
// Check if user has available tokens or unlimited plan
|
||||
if (user.SubscriptionPlan?.IsUnlimitedTokens == true || user.AITokensUsed < user.AITokenLimit)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Data\NexusReader.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,24 +5,24 @@ using Microsoft.Extensions.Options;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public class BillingService : IBillingService
|
||||
{
|
||||
private readonly AppDbContext _dbContext;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
private readonly StripeSettings _stripeSettings;
|
||||
private readonly ILogger<BillingService> _logger;
|
||||
|
||||
public BillingService(
|
||||
AppDbContext dbContext,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
UserManager<NexusUser> userManager,
|
||||
IOptions<StripeSettings> stripeSettings,
|
||||
ILogger<BillingService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_userManager = userManager;
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
_logger = logger;
|
||||
@@ -55,7 +55,8 @@ public class BillingService : IBillingService
|
||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||
}
|
||||
|
||||
var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||
if (plan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = plan.Id;
|
||||
@@ -82,7 +83,8 @@ public class BillingService : IBillingService
|
||||
return false;
|
||||
}
|
||||
|
||||
var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
if (freePlan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = freePlan.Id;
|
||||
|
||||
@@ -46,34 +46,35 @@ public class EpubService : IEpubService
|
||||
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
|
||||
}
|
||||
|
||||
EpubBook book;
|
||||
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;
|
||||
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
|
||||
var readingOrder = bookRef.GetReadingOrder();
|
||||
|
||||
if (book.ReadingOrder == null || !book.ReadingOrder.Any())
|
||||
if (readingOrder == null || !readingOrder.Any())
|
||||
{
|
||||
return Result.Fail("The EPUB has no readable content files in ReadingOrder.");
|
||||
}
|
||||
|
||||
// Ensure index is within bounds
|
||||
if (chapterIndex < 0 || chapterIndex >= book.ReadingOrder.Count)
|
||||
if (chapterIndex < 0 || chapterIndex >= readingOrder.Count)
|
||||
{
|
||||
chapterIndex = 0; // Default to first chapter
|
||||
}
|
||||
|
||||
var chapter = book.ReadingOrder[chapterIndex];
|
||||
var chapterTitle = chapter.FilePath ?? $"Chapter {chapterIndex + 1}";
|
||||
var chapterRef = readingOrder[chapterIndex];
|
||||
|
||||
var paragraphs = ExtractParagraphs(chapter.Content);
|
||||
// Try to find a better title from navigation (TOC)
|
||||
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)
|
||||
{
|
||||
var sanitizedContent = SanitizeParagraph(p);
|
||||
@@ -99,7 +100,7 @@ public class EpubService : IEpubService
|
||||
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
|
||||
}
|
||||
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, book.ReadingOrder.Count, chapterTitle));
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -162,4 +163,25 @@ public class EpubService : IEpubService
|
||||
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.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Helpers;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Polly;
|
||||
using Polly.Registry;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -40,6 +40,15 @@
|
||||
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" />
|
||||
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:
|
||||
<!-- Fallback circle -->
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -42,6 +42,7 @@
|
||||
/// <summary>Fallback static dialogue shown when no live AI content is available.</summary>
|
||||
[Parameter] public string Dialogue { get; set; } = string.Empty;
|
||||
[Parameter] public List<string> Actions { get; set; } = new();
|
||||
[Parameter] public string FullPageContent { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
||||
|
||||
private string _displayedText = string.Empty;
|
||||
@@ -76,8 +77,11 @@
|
||||
|
||||
try
|
||||
{
|
||||
_packet = await Coordinator.RequestSummaryAndQuizAsync(
|
||||
$"[ID: {ContextBlockId}]\n{Dialogue}");
|
||||
var contentToAnalyze = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? FullPageContent
|
||||
: $"[ID: {ContextBlockId}]\n{Dialogue}";
|
||||
|
||||
_packet = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze);
|
||||
|
||||
var summary = _packet?.Summary;
|
||||
|
||||
|
||||
@@ -18,39 +18,7 @@
|
||||
}
|
||||
</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 {
|
||||
[Parameter] public string Answer { get; set; } = string.Empty;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.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,6 +2,8 @@
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
<div class="toolbar-top">
|
||||
@@ -36,13 +38,16 @@
|
||||
<button class="toolbar-item" title="Global Settings">
|
||||
<NexusIcon Name="settings" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Logout">
|
||||
<NexusIcon Name="log-out" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||
}
|
||||
|
||||
private async Task HandleClearCache()
|
||||
@@ -56,8 +61,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/", true);
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.intelligence-toolbar {
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
background: #080808;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.03);
|
||||
background: #0D0D0D;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -10,6 +10,7 @@
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
box-shadow: inset -2px 0 10px rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
.toolbar-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #444;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -37,11 +38,15 @@
|
||||
.toolbar-item:hover {
|
||||
color: var(--nexus-neon);
|
||||
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 {
|
||||
color: var(--nexus-neon);
|
||||
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 {
|
||||
|
||||
@@ -55,12 +55,14 @@
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
QuizService.OnQuizUpdated += () => InvokeAsync(StateHasChanged);
|
||||
QuizService.OnQuizUpdated += HandleUpdate;
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
QuizService.OnQuizUpdated -= StateHasChanged;
|
||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||
}
|
||||
|
||||
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
<div class="ai-actions">
|
||||
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
|
||||
<button class="action-btn ghost" @onclick="Close">Zamknij</button>
|
||||
<button class="action-btn ghost" @onclick="CloseAsync">Zamknij</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
<div class="ai-actions">
|
||||
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
|
||||
<button class="action-btn ghost" @onclick="Close">Pomiń</button>
|
||||
<button class="action-btn ghost" @onclick="CloseAsync">Pomiń</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -76,7 +76,11 @@
|
||||
private async Task RequestSummary()
|
||||
{
|
||||
IsLoading = true;
|
||||
Packet = await Coordinator.RequestSummaryAndQuizAsync(SelectedText);
|
||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
Packet = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
@@ -85,12 +89,12 @@
|
||||
IsLoading = true;
|
||||
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
|
||||
IsLoading = false;
|
||||
Close();
|
||||
await CloseAsync();
|
||||
}
|
||||
|
||||
private void Close()
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
Packet = null;
|
||||
InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
|
||||
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
GraphService.OnLoadingChanged += HandleLoadingChange;
|
||||
}
|
||||
|
||||
private async void HandleGraphUpdate()
|
||||
private async Task HandleGraphUpdate()
|
||||
{
|
||||
if (_module == null) return;
|
||||
|
||||
@@ -62,13 +62,13 @@
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async void HandleActiveNodeChange(string nodeId)
|
||||
private async Task HandleActiveNodeChange(string nodeId)
|
||||
{
|
||||
if (_module == null) return;
|
||||
await _module.InvokeVoidAsync("setActiveNode", nodeId);
|
||||
}
|
||||
|
||||
private async void HandleLoadingChange(bool isLoading)
|
||||
private async Task HandleLoadingChange(bool isLoading)
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
if (GraphService.CurrentGraphData != null)
|
||||
{
|
||||
HandleGraphUpdate();
|
||||
await HandleGraphUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@
|
||||
[JSInvokable]
|
||||
public async Task OnNodeClicked(string nodeId)
|
||||
{
|
||||
InteractionService.NotifyNodeSelected(nodeId);
|
||||
await InteractionService.NotifyNodeSelected(nodeId);
|
||||
|
||||
if (OnNodeSelected.HasDelegate)
|
||||
{
|
||||
@@ -109,7 +109,7 @@
|
||||
}
|
||||
|
||||
|
||||
private async void HandleFocusSimulation()
|
||||
private async Task HandleFocusSimulation()
|
||||
{
|
||||
if (_module == null) return;
|
||||
try
|
||||
|
||||
@@ -98,3 +98,13 @@
|
||||
filter: drop-shadow(0 0 12px var(--nexus-neon));
|
||||
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,15 +52,16 @@
|
||||
private bool _isJsInitialized;
|
||||
private ElementReference _containerRef;
|
||||
|
||||
protected override void OnInitialized()
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Coordinator.Clear();
|
||||
ThemeService.OnThemeChanged += StateHasChanged;
|
||||
await Coordinator.ClearAsync();
|
||||
ThemeService.OnThemeChanged += HandleUpdate;
|
||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||
|
||||
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
||||
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
||||
InteractionService.OnTextSelected += HandleTextSelected;
|
||||
SyncService.OnProgressReceived += HandleSyncProgressReceived;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
@@ -113,60 +114,56 @@
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void HandleBlockReached(string blockId, string content)
|
||||
public async Task HandleBlockReached(string blockId, string content)
|
||||
{
|
||||
Coordinator.OnBlockReached(blockId, content);
|
||||
await Coordinator.OnBlockReachedAsync(blockId, content);
|
||||
|
||||
// Debounce sync update (simple version: every 5 seconds or on a timer)
|
||||
_ = SyncService.UpdateProgressAsync(blockId);
|
||||
await SyncService.UpdateProgressAsync(blockId);
|
||||
}
|
||||
|
||||
private void HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||
{
|
||||
// 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.
|
||||
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
|
||||
|
||||
// Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
|
||||
// but we can assume incoming syncs are from other active devices)
|
||||
_ = InvokeAsync(async () => {
|
||||
await ScrollToNodeAsync(blockId);
|
||||
StateHasChanged();
|
||||
});
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
|
||||
public async Task HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
|
||||
{
|
||||
Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}");
|
||||
_selectedText = text;
|
||||
_selectedBlockId = blockId;
|
||||
_selectionCoords = coords;
|
||||
StateHasChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void HandleSelectionCleared()
|
||||
public async Task HandleSelectionCleared()
|
||||
{
|
||||
_selectedText = string.Empty;
|
||||
_selectionCoords = null;
|
||||
StateHasChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void HandleScrollRequested(string blockId)
|
||||
private async Task HandleScrollRequested(string blockId)
|
||||
{
|
||||
_ = ScrollToNodeAsync(blockId);
|
||||
await ScrollToNodeAsync(blockId);
|
||||
}
|
||||
|
||||
private async void HandleHighlightRequested(string blockId)
|
||||
private async Task HandleHighlightRequested(string blockId)
|
||||
{
|
||||
_highlightedBlockId = blockId;
|
||||
StateHasChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await Task.Delay(3000); // Highlight for 3 seconds
|
||||
if (_highlightedBlockId == blockId)
|
||||
{
|
||||
_highlightedBlockId = null;
|
||||
StateHasChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,9 +209,11 @@
|
||||
catch { }
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ThemeService.OnThemeChanged -= StateHasChanged;
|
||||
ThemeService.OnThemeChanged -= HandleUpdate;
|
||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||
|
||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
.reader-canvas {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 2rem 0;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
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 {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
position: relative;
|
||||
padding: 0 1.5rem 15rem 1.5rem; /* Large padding-bottom for reachability */
|
||||
}
|
||||
|
||||
.block-wrapper {
|
||||
@@ -20,6 +51,68 @@
|
||||
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 {
|
||||
background: rgba(0, 243, 255, 0.08);
|
||||
box-shadow: 0 0 20px rgba(0, 243, 255, 0.15);
|
||||
|
||||
@@ -36,10 +36,9 @@
|
||||
NavigationService.OnNavigationChanged += HandleNavigationChanged;
|
||||
}
|
||||
|
||||
private Task HandleNavigationChanged()
|
||||
private async Task HandleNavigationChanged()
|
||||
{
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private int CalculateProgress()
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
}
|
||||
|
||||
.navigation-controls {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -51,18 +53,23 @@
|
||||
|
||||
.chapter-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-weight: 600;
|
||||
max-width: 180px;
|
||||
font-size: 0.75rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chapter-count {
|
||||
|
||||
@@ -35,10 +35,7 @@
|
||||
<span>Asystent AI</span>
|
||||
</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>
|
||||
</div>
|
||||
@@ -77,8 +74,8 @@
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
QuizService.OnQuizUpdated += StateHasChanged;
|
||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||
QuizService.OnQuizUpdated += HandleUpdate;
|
||||
|
||||
var context = PlatformService.GetDeviceContext();
|
||||
if (context.IsSuccess)
|
||||
@@ -93,11 +90,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/", true);
|
||||
}
|
||||
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
@@ -115,9 +108,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
QuizService.OnQuizUpdated -= StateHasChanged;
|
||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.intelligence-sidebar {
|
||||
|
||||
@@ -59,6 +59,13 @@
|
||||
<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">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
@@ -83,11 +90,30 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorCode { get; set; }
|
||||
|
||||
private LoginModel _loginModel = new();
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
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()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
@@ -95,7 +121,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password);
|
||||
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
|
||||
if (success) NavigationManager.NavigateTo("/");
|
||||
else _errorMessage = "Nieprawidłowy e-mail lub hasło.";
|
||||
}
|
||||
@@ -114,5 +140,7 @@
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
QuizState.OnQuizRequested += HandleQuizRequested;
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
QuizState.OnQuizRequested += HandleQuizRequestedAsync;
|
||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||
await FocusMode.InitializeAsync();
|
||||
}
|
||||
|
||||
@@ -54,16 +54,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleQuizRequested(string blockId)
|
||||
private async Task HandleQuizRequestedAsync(string blockId)
|
||||
{
|
||||
_activeQuizBlockId = blockId;
|
||||
StateHasChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
QuizState.OnQuizRequested -= HandleQuizRequested;
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
QuizState.OnQuizRequested -= HandleQuizRequestedAsync;
|
||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||
|
||||
if (_interopModule != null && _keydownHandler != null)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.home-reader-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
@page "/not-found"
|
||||
@layout Layout.MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
<div class="not-found-preloader">
|
||||
<div class="preloader-robot">
|
||||
<NexusIcon Name="robot" Size="64" class="neon-pulse" />
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Synchronizowanie przestrzeni Nexus...</NexusTypography>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
.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; }
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<NexusReader.UI.Shared.Pages.NotFound />
|
||||
</NotFound>
|
||||
</Router>
|
||||
</ChildContent>
|
||||
<ErrorContent Context="ex">
|
||||
|
||||
@@ -6,7 +6,7 @@ public sealed class FocusModeService : IFocusModeService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
public bool IsFocusModeActive { get; private set; }
|
||||
public event Action? OnFocusModeChanged;
|
||||
public event Func<Task>? OnFocusModeChanged;
|
||||
|
||||
public FocusModeService(IJSRuntime jsRuntime)
|
||||
{
|
||||
@@ -21,7 +21,7 @@ public sealed class FocusModeService : IFocusModeService
|
||||
if (value == "true" && !IsFocusModeActive)
|
||||
{
|
||||
IsFocusModeActive = true;
|
||||
OnFocusModeChanged?.Invoke();
|
||||
if (OnFocusModeChanged != null) await OnFocusModeChanged();
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -33,7 +33,7 @@ public sealed class FocusModeService : IFocusModeService
|
||||
public async Task ToggleAsync()
|
||||
{
|
||||
IsFocusModeActive = !IsFocusModeActive;
|
||||
OnFocusModeChanged?.Invoke();
|
||||
if (OnFocusModeChanged != null) await OnFocusModeChanged();
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace NexusReader.UI.Shared.Services;
|
||||
public interface IFocusModeService
|
||||
{
|
||||
bool IsFocusModeActive { get; }
|
||||
event Action? OnFocusModeChanged;
|
||||
event Func<Task>? OnFocusModeChanged;
|
||||
Task InitializeAsync();
|
||||
Task ToggleAsync();
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ public interface IKnowledgeGraphService
|
||||
string? ActiveNodeId { get; }
|
||||
bool IsLoading { get; }
|
||||
|
||||
event Action? OnGraphUpdated;
|
||||
event Action<string>? OnActiveNodeChanged;
|
||||
event Action<bool>? OnLoadingChanged;
|
||||
event Func<Task>? OnGraphUpdated;
|
||||
event Func<string, Task>? OnActiveNodeChanged;
|
||||
event Func<bool, Task>? OnLoadingChanged;
|
||||
|
||||
void UpdateGraph(GraphDataDto newData);
|
||||
void SetActiveNode(string nodeId);
|
||||
void SetLoading(bool isLoading);
|
||||
void Clear();
|
||||
Task UpdateGraph(GraphDataDto newData);
|
||||
Task SetActiveNode(string nodeId);
|
||||
Task SetLoading(bool isLoading);
|
||||
Task Clear();
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ public interface IQuizStateService
|
||||
bool IsHydrating { get; }
|
||||
bool HasNewQuiz { get; }
|
||||
|
||||
event Action<string>? OnQuizRequested;
|
||||
event Action? OnQuizUpdated;
|
||||
event Func<string, Task>? OnQuizRequested;
|
||||
event Func<Task>? OnQuizUpdated;
|
||||
|
||||
void RequestQuiz(string blockId);
|
||||
void SetQuiz(string? blockId, QuizDto quiz);
|
||||
void SetHydrating(bool hydrating);
|
||||
void MarkQuizAsSeen();
|
||||
Task RequestQuiz(string blockId);
|
||||
Task SetQuiz(string? blockId, QuizDto? quiz);
|
||||
Task SetHydrating(bool hydrating);
|
||||
Task MarkQuizAsSeen();
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IReaderInteractionService
|
||||
{
|
||||
event Action<string>? OnNodeSelected;
|
||||
event Action<string>? OnScrollToBlockRequested;
|
||||
event Action<string>? OnHighlightBlockRequested;
|
||||
event Action<string, string, SelectionCoordinates>? OnTextSelected;
|
||||
event Func<string, Task>? OnNodeSelected;
|
||||
event Func<string, Task>? OnScrollToBlockRequested;
|
||||
event Func<string, Task>? OnHighlightBlockRequested;
|
||||
event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
||||
|
||||
void NotifyNodeSelected(string nodeId);
|
||||
void RequestScrollToBlock(string blockId);
|
||||
void RequestHighlightBlock(string blockId);
|
||||
void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
|
||||
Task NotifyNodeSelected(string nodeId);
|
||||
Task RequestScrollToBlock(string blockId);
|
||||
Task RequestHighlightBlock(string blockId);
|
||||
Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
|
||||
}
|
||||
|
||||
public record SelectionCoordinates(double Top, double Left, double Width);
|
||||
|
||||
@@ -6,6 +6,6 @@ public interface ISyncService
|
||||
{
|
||||
Task<Result> InitializeAsync();
|
||||
Task<Result> UpdateProgressAsync(string pageId);
|
||||
event Action<string, DateTime> OnProgressReceived;
|
||||
event Func<string, DateTime, Task> OnProgressReceived;
|
||||
Task DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@ namespace NexusReader.UI.Shared.Services;
|
||||
public interface IThemeService
|
||||
{
|
||||
bool IsLightMode { get; }
|
||||
event Action? OnThemeChanged;
|
||||
void ToggleTheme();
|
||||
event Func<Task>? OnThemeChanged;
|
||||
Task ToggleTheme();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace NexusReader.UI.Shared.Services;
|
||||
public interface IIdentityService
|
||||
{
|
||||
Task<bool> RegisterAsync(string email, string password);
|
||||
Task<bool> LoginAsync(string email, string password);
|
||||
Task<bool> LoginAsync(string email, string password, bool rememberMe = false);
|
||||
Task LogoutAsync();
|
||||
Task<UserProfile?> GetProfileAsync();
|
||||
Task<bool> RefreshTokenAsync();
|
||||
@@ -45,7 +45,7 @@ public class IdentityService : IIdentityService
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(string email, string password)
|
||||
public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
|
||||
|
||||
@@ -59,7 +59,22 @@ public class IdentityService : IIdentityService
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
|
||||
}
|
||||
_authStateProvider.NotifyUserAuthentication(result.AccessToken);
|
||||
|
||||
// Option A: Fetch profile to get claims
|
||||
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.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback if profile fetch fails
|
||||
_authStateProvider.NotifyUserAuthentication(email, "unknown");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -71,6 +86,8 @@ public class IdentityService : IIdentityService
|
||||
{
|
||||
_storageService.RemoveSecure(TokenKey);
|
||||
_storageService.RemoveSecure(RefreshTokenKey);
|
||||
_storageService.RemoveSecure("nexus_user_email");
|
||||
_storageService.RemoveSecure("nexus_user_tenant");
|
||||
_authStateProvider.NotifyUserLogout();
|
||||
}
|
||||
|
||||
@@ -105,7 +122,15 @@ public class IdentityService : IIdentityService
|
||||
{
|
||||
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.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
_interactionService.OnNodeSelected += HandleNodeSelected;
|
||||
}
|
||||
|
||||
private void HandleNodeSelected(string nodeId)
|
||||
private async Task HandleNodeSelected(string nodeId)
|
||||
{
|
||||
_interactionService.RequestScrollToBlock(nodeId);
|
||||
_interactionService.RequestHighlightBlock(nodeId);
|
||||
await _interactionService.RequestScrollToBlock(nodeId);
|
||||
await _interactionService.RequestHighlightBlock(nodeId);
|
||||
}
|
||||
|
||||
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global")
|
||||
@@ -48,8 +48,8 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
|
||||
LogGeneratingGraph(tenantId);
|
||||
|
||||
_graphService.Clear();
|
||||
_graphService.SetLoading(true);
|
||||
await _graphService.Clear();
|
||||
await _graphService.SetLoading(true);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -59,7 +59,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
var packet = result.Value;
|
||||
if (packet.Graph != null)
|
||||
{
|
||||
_graphService.UpdateGraph(packet.Graph);
|
||||
await _graphService.UpdateGraph(packet.Graph);
|
||||
OnGraphUpdated?.Invoke(packet.Graph);
|
||||
await _platformService.VibrateSuccessAsync();
|
||||
}
|
||||
@@ -71,10 +71,10 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void OnBlockReached(string blockId, string content)
|
||||
public async Task OnBlockReachedAsync(string blockId, string content)
|
||||
{
|
||||
// Only update active node for "TU JESTEŚ" logic, do NOT trigger highlight here
|
||||
_graphService.SetActiveNode(blockId);
|
||||
await _graphService.SetActiveNode(blockId);
|
||||
}
|
||||
|
||||
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
||||
@@ -109,9 +109,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
_graphService.Clear();
|
||||
await _graphService.Clear();
|
||||
_quizService.SetQuiz(null, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,36 +8,36 @@ public sealed class KnowledgeGraphService : IKnowledgeGraphService
|
||||
public string? ActiveNodeId { get; private set; }
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
public event Action? OnGraphUpdated;
|
||||
public event Action<string>? OnActiveNodeChanged;
|
||||
public event Action<bool>? OnLoadingChanged;
|
||||
public event Func<Task>? OnGraphUpdated;
|
||||
public event Func<string, Task>? OnActiveNodeChanged;
|
||||
public event Func<bool, Task>? OnLoadingChanged;
|
||||
|
||||
public void UpdateGraph(GraphDataDto newData)
|
||||
public async Task UpdateGraph(GraphDataDto newData)
|
||||
{
|
||||
CurrentGraphData = newData;
|
||||
IsLoading = false;
|
||||
OnLoadingChanged?.Invoke(false);
|
||||
OnGraphUpdated?.Invoke();
|
||||
if (OnLoadingChanged != null) await OnLoadingChanged(false);
|
||||
if (OnGraphUpdated != null) await OnGraphUpdated();
|
||||
}
|
||||
|
||||
public void SetActiveNode(string nodeId)
|
||||
public async Task SetActiveNode(string nodeId)
|
||||
{
|
||||
if (ActiveNodeId == nodeId) return;
|
||||
ActiveNodeId = nodeId;
|
||||
OnActiveNodeChanged?.Invoke(nodeId);
|
||||
if (OnActiveNodeChanged != null) await OnActiveNodeChanged(nodeId);
|
||||
}
|
||||
|
||||
public void SetLoading(bool isLoading)
|
||||
public async Task SetLoading(bool isLoading)
|
||||
{
|
||||
IsLoading = isLoading;
|
||||
OnLoadingChanged?.Invoke(isLoading);
|
||||
if (OnLoadingChanged != null) await OnLoadingChanged(isLoading);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
public async Task Clear()
|
||||
{
|
||||
CurrentGraphData = null;
|
||||
ActiveNodeId = null;
|
||||
IsLoading = false;
|
||||
OnGraphUpdated?.Invoke();
|
||||
if (OnGraphUpdated != null) await OnGraphUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,15 +19,30 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _storageService.GetSecureString(TokenKey);
|
||||
var token = result.IsSuccess ? result.Value : null;
|
||||
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
var token = tokenResult.IsSuccess ? tokenResult.Value : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
|
||||
// For opaque tokens, we read the user info that was stored during login
|
||||
var emailResult = await _storageService.GetSecureString("nexus_user_email");
|
||||
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
|
||||
|
||||
var claims = new List<Claim>();
|
||||
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Name, emailResult.Value));
|
||||
claims.Add(new Claim(ClaimTypes.Email, emailResult.Value));
|
||||
}
|
||||
if (tenantIdResult.IsSuccess && !string.IsNullOrEmpty(tenantIdResult.Value))
|
||||
{
|
||||
claims.Add(new Claim("TenantId", tenantIdResult.Value));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "OpaqueBearer");
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
|
||||
return new AuthenticationState(user);
|
||||
@@ -38,9 +53,16 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyUserAuthentication(string token)
|
||||
public void NotifyUserAuthentication(string email, string tenantId)
|
||||
{
|
||||
var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.Name, email),
|
||||
new Claim(ClaimTypes.Email, email),
|
||||
new Claim("TenantId", tenantId)
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "OpaqueBearer");
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
var authState = Task.FromResult(new AuthenticationState(user));
|
||||
NotifyAuthenticationStateChanged(authState);
|
||||
@@ -52,30 +74,4 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
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 HasNewQuiz { get; private set; }
|
||||
|
||||
public event Action<string>? OnQuizRequested;
|
||||
public event Action? OnQuizUpdated;
|
||||
public event Func<string, Task>? OnQuizRequested;
|
||||
public event Func<Task>? OnQuizUpdated;
|
||||
|
||||
public void RequestQuiz(string blockId)
|
||||
public async Task RequestQuiz(string blockId)
|
||||
{
|
||||
CurrentQuizBlockId = blockId;
|
||||
OnQuizRequested?.Invoke(blockId);
|
||||
if (OnQuizRequested != null) await OnQuizRequested(blockId);
|
||||
}
|
||||
|
||||
public void SetQuiz(string? blockId, QuizDto quiz)
|
||||
public async Task SetQuiz(string? blockId, QuizDto? quiz)
|
||||
{
|
||||
CurrentQuizBlockId = blockId;
|
||||
CurrentQuiz = quiz;
|
||||
IsHydrating = false;
|
||||
HasNewQuiz = true;
|
||||
OnQuizUpdated?.Invoke();
|
||||
HasNewQuiz = quiz != null;
|
||||
if (OnQuizUpdated != null) await OnQuizUpdated();
|
||||
}
|
||||
|
||||
public void SetHydrating(bool hydrating)
|
||||
public async Task SetHydrating(bool hydrating)
|
||||
{
|
||||
IsHydrating = hydrating;
|
||||
OnQuizUpdated?.Invoke();
|
||||
if (OnQuizUpdated != null) await OnQuizUpdated();
|
||||
}
|
||||
|
||||
public void MarkQuizAsSeen()
|
||||
public async Task MarkQuizAsSeen()
|
||||
{
|
||||
if (!HasNewQuiz) return;
|
||||
HasNewQuiz = false;
|
||||
OnQuizUpdated?.Invoke();
|
||||
if (OnQuizUpdated != null) await OnQuizUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,28 @@ namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class ReaderInteractionService : IReaderInteractionService
|
||||
{
|
||||
public event Action<string>? OnNodeSelected;
|
||||
public event Action<string>? OnScrollToBlockRequested;
|
||||
public event Action<string>? OnHighlightBlockRequested;
|
||||
public event Action<string, string, SelectionCoordinates>? OnTextSelected;
|
||||
public event Func<string, Task>? OnNodeSelected;
|
||||
public event Func<string, Task>? OnScrollToBlockRequested;
|
||||
public event Func<string, Task>? OnHighlightBlockRequested;
|
||||
public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
||||
|
||||
public void NotifyNodeSelected(string nodeId)
|
||||
public async Task NotifyNodeSelected(string nodeId)
|
||||
{
|
||||
OnNodeSelected?.Invoke(nodeId);
|
||||
if (OnNodeSelected != null) await OnNodeSelected(nodeId);
|
||||
}
|
||||
|
||||
public void RequestScrollToBlock(string blockId)
|
||||
public async Task RequestScrollToBlock(string blockId)
|
||||
{
|
||||
OnScrollToBlockRequested?.Invoke(blockId);
|
||||
if (OnScrollToBlockRequested != null) await OnScrollToBlockRequested(blockId);
|
||||
}
|
||||
|
||||
public void RequestHighlightBlock(string blockId)
|
||||
public async Task RequestHighlightBlock(string blockId)
|
||||
{
|
||||
OnHighlightBlockRequested?.Invoke(blockId);
|
||||
if (OnHighlightBlockRequested != null) await OnHighlightBlockRequested(blockId);
|
||||
}
|
||||
|
||||
public void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords)
|
||||
public async Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords)
|
||||
{
|
||||
OnTextSelected?.Invoke(text, blockId, coords);
|
||||
if (OnTextSelected != null) await OnTextSelected(text, blockId, coords);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
private bool _isInitialized;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
|
||||
public event Action<string, DateTime>? OnProgressReceived;
|
||||
public event Func<string, DateTime, Task>? OnProgressReceived;
|
||||
|
||||
public SyncService(
|
||||
HttpClient httpClient,
|
||||
@@ -44,9 +44,9 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<string, DateTime>("ProgressUpdated", (pageId, timestamp) =>
|
||||
_hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) =>
|
||||
{
|
||||
OnProgressReceived?.Invoke(pageId, timestamp);
|
||||
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
|
||||
});
|
||||
|
||||
try
|
||||
|
||||
@@ -3,11 +3,11 @@ namespace NexusReader.UI.Shared.Services;
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
public bool IsLightMode { get; private set; } = false;
|
||||
public event Action? OnThemeChanged;
|
||||
public event Func<Task>? OnThemeChanged;
|
||||
|
||||
public void ToggleTheme()
|
||||
public async Task ToggleTheme()
|
||||
{
|
||||
IsLightMode = !IsLightMode;
|
||||
OnThemeChanged?.Invoke();
|
||||
if (OnThemeChanged != null) await OnThemeChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,28 @@
|
||||
|
||||
.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 {
|
||||
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;
|
||||
|
||||
@@ -134,9 +134,10 @@ export function updateData(data) {
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
|
||||
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
|
||||
.call(e => e.transition().duration(500).attr("opacity", 1)),
|
||||
.style("opacity", 0)
|
||||
.call(enter => enter.transition().duration(500).style("opacity", 1)),
|
||||
update => update,
|
||||
exit => exit.remove()
|
||||
exit => exit.transition().duration(500).style("opacity", 0).remove()
|
||||
);
|
||||
|
||||
// Update Nodes
|
||||
@@ -146,8 +147,9 @@ export function updateData(data) {
|
||||
.join(
|
||||
enter => {
|
||||
const g = enter.append("g")
|
||||
.attr("class", "node-group")
|
||||
.attr("class", "node-group neon-flash-node")
|
||||
.style("cursor", "pointer")
|
||||
.style("opacity", 0)
|
||||
.on("click", (e, d) => {
|
||||
currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
|
||||
setActiveNode(d.id);
|
||||
@@ -162,8 +164,7 @@ export function updateData(data) {
|
||||
if (d.type === 'Rule') return '#ff4444';
|
||||
return "url(#nebulaGlow)";
|
||||
})
|
||||
.attr("opacity", 0)
|
||||
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
|
||||
.attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
|
||||
|
||||
g.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
@@ -187,10 +188,12 @@ export function updateData(data) {
|
||||
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
||||
.attr("font-size", "0.8rem");
|
||||
|
||||
g.transition().duration(500).style("opacity", 1);
|
||||
|
||||
return g;
|
||||
},
|
||||
update => update,
|
||||
exit => exit.remove()
|
||||
update => update.classed("neon-flash-node", false),
|
||||
exit => exit.transition().duration(500).style("opacity", 0).remove()
|
||||
);
|
||||
|
||||
simulation.nodes(data.nodes);
|
||||
@@ -223,7 +226,11 @@ export function setActiveNode(nodeId) {
|
||||
if (!svgElement || !node) return;
|
||||
|
||||
const targetNode = node.filter(d => d.id === nodeId);
|
||||
if (targetNode.empty()) return;
|
||||
if (targetNode.empty()) {
|
||||
dimNodes(null);
|
||||
badge.style("display", "none");
|
||||
return;
|
||||
}
|
||||
|
||||
const d = targetNode.datum();
|
||||
|
||||
@@ -235,6 +242,9 @@ export function setActiveNode(nodeId) {
|
||||
badge.style("display", "block").datum(d);
|
||||
badge.attr("transform", `translate(${d.x},${d.y})`);
|
||||
|
||||
// Dim others
|
||||
dimNodes(nodeId);
|
||||
|
||||
// Smooth transition
|
||||
svgElement.transition().duration(1000).call(
|
||||
zoomBehavior.transform,
|
||||
@@ -242,6 +252,24 @@ export function setActiveNode(nodeId) {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Net.Http.Headers;
|
||||
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)
|
||||
{
|
||||
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,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,6 +4,9 @@ using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Application;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
@@ -30,9 +33,33 @@ builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
// AI & Content Services
|
||||
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.AddScoped<IEpubService, WasmEpubService>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -96,6 +96,18 @@ builder.Services.ConfigureApplicationCookie(options =>
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
options.Events.OnRedirectToLogin = context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api"))
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Response.Redirect(context.RedirectUri);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.Configure<IdentityOptions>(options =>
|
||||
@@ -135,7 +147,8 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
var dbContext = services.GetRequiredService<NexusReader.Infrastructure.Persistence.AppDbContext>();
|
||||
var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Data.Persistence.AppDbContext>>();
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
int maxRetries = 5;
|
||||
int delayMs = 2000;
|
||||
@@ -354,31 +367,57 @@ app.MapGet("/identity/login/google", (string? returnUrl) =>
|
||||
app.MapGet("/identity/callback/google", async (
|
||||
HttpContext context,
|
||||
SignInManager<NexusUser> signInManager,
|
||||
UserManager<NexusUser> userManager) =>
|
||||
UserManager<NexusUser> userManager,
|
||||
ILogger<Program> logger) =>
|
||||
{
|
||||
var info = await signInManager.GetExternalLoginInfoAsync();
|
||||
if (info == null) return Results.Redirect("/account/login?error=ExternalLoginFailed");
|
||||
if (info == null)
|
||||
{
|
||||
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);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
logger.LogInformation("User logged in via Google: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email));
|
||||
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
|
||||
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
|
||||
if (email != null)
|
||||
{
|
||||
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
|
||||
var createResult = await userManager.CreateAsync(user);
|
||||
|
||||
if (createResult.Succeeded)
|
||||
{
|
||||
await userManager.AddLoginAsync(user, info);
|
||||
await signInManager.SignInAsync(user, isPersistent: false);
|
||||
logger.LogInformation("New user provisioned via Google: {Email}", email);
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user