refactor: consolidate project structure by migrating authentication, identity, and shared UI components while removing legacy Web Client files.

This commit is contained in:
2026-04-28 20:23:40 +02:00
parent 131981992c
commit 10efed0369
124 changed files with 2822 additions and 2213 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor) - **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI". - **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
- **Architecture Role:** Lead Clean Architecture Specialist. - **Architecture Role:** Lead Clean Architecture Specialist.
- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator] - **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas]
- **Technical Constraints:** - **Technical Constraints:**
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level. - **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level.
- **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components. - **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components.
+39
View File
@@ -0,0 +1,39 @@
---
name: nexus-identity-saas
description: Standards for Identity, Authentication, and SaaS feature implementations
---
# Identity & SaaS Integration
- **Core Identity Model:**
- Extend `IdentityUser` to create a custom `NexusUser` model containing SaaS-specific properties (e.g., `AITokenLimit`, `AITokensUsed`, `TenantId`, `CurrentPlan`).
- Place core domain models in the core project layer (e.g., `NexusArchitect.Core` or `NexusReader.Domain`).
- Configure `ApplicationDbContext` to inherit from `IdentityDbContext<NexusUser>` and map custom fields and relationships correctly.
- **Authentication Endpoints & Providers:**
- Use native ASP.NET Core Identity API endpoints (`/register`, `/login`, `/refresh`) or scaffolded Razor components in Blazor (`Components/Account/Pages`).
- Integrate OAuth2 providers (like Google, Facebook, Microsoft) natively via ASP.NET Core's external login providers.
- Utilize `SignInManager<TUser>` and `UserManager<TUser>` for custom login logic and user management.
- **Service Configuration & Policies:**
- Register Identity using `builder.Services.AddDefaultIdentity<NexusUser>()` or `AddIdentity<NexusUser, IdentityRole>()` followed by `.AddEntityFrameworkStores<ApplicationDbContext>()`.
- Configure `IdentityOptions` in `Program.cs` to enforce strict security standards:
- **Password:** `RequireDigit`, `RequireLowercase`, `RequireUppercase`, `RequireNonAlphanumeric`, `RequiredLength` (min 8).
- **Lockout:** Set `MaxFailedAccessAttempts` and `DefaultLockoutTimeSpan` to prevent brute-force attacks.
- **User:** Enforce `RequireUniqueEmail = true`.
- **Authorization & Policies:**
- Implement Roles and Claims-based authorization.
- Create robust Policies (e.g., `ProUser`) and use custom `Requirement` handlers for specific business logic like checking if `AITokensUsed < AITokenLimit`.
- **Mobile / Blazor Hybrid Auth State:**
- Ensure authentication state persists securely within the MAUI container.
- Store JWT tokens and sensitive session data in `SecureStorage`.
- Provide a seamless mechanism to restore the `AuthenticationStateProvider` on app launch if the token is valid.
- **SaaS Features & Webhooks:**
- Integrate third-party payment/subscription providers (e.g., Stripe) using secure webhooks.
- Sync external subscription status with internal user claims and limits (e.g., upgrade `AITokenLimit` upon a webhook success event for a "Pro" plan).
- **Verification:**
- Write unit tests for custom authorization handlers and token limit logic.
- Ensure the UI handles unauthorized and out-of-tokens states gracefully and points users to subscription management.
+2 -2
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<AndroidSdkDirectory>/home/debian/android-sdk</AndroidSdkDirectory> <AndroidSdkDirectory>$(HOME)/android-sdk</AndroidSdkDirectory>
<JavaSdkDirectory>/home/debian/java/jdk-17.0.10+7</JavaSdkDirectory> <JavaSdkDirectory>/usr/lib/jvm/java-21-openjdk-amd64</JavaSdkDirectory>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+45
View File
@@ -0,0 +1,45 @@
# NexusArchitect - User Management Implementation Backlog
**Project:** AI-Powered E-book Reader SaaS
**Architecture:** .NET 10, Blazor Hybrid, MAUI, ASP.NET Core Identity
**Primary Goal:** Implement a secure, scalable authentication and authorization system with SaaS-specific features (AI token limits, subscription tiers).
---
## Phase 0: Backend Foundations (ASP.NET Core & EF Core)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **BACK-1** | Define Extended `NexusUser` Model | **Description:** Create a `NexusUser` class inheriting from `IdentityUser`. Add custom properties for SaaS logic.<br>**AC:**<br>- [ ] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).<br>- [ ] Model placed in `NexusArchitect.Core` project. | C# / Identity |
| **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext<NexusUser>`.<br>**AC:**<br>- [ ] Mapped standard Identity tables (Users, Roles, Claims).<br>- [ ] Configured 1-to-Many relationship between `NexusUser` and `Ebooks`. | EF Core |
| **BACK-3** | Database Schema Migration | **Description:** Generate and apply the initial migration for Identity tables.<br>**AC:**<br>- [ ] SQL schema contains all 7+ standard Identity tables.<br>- [ ] Custom `NexusUser` fields are correctly reflected in the `AspNetUsers` table. | EF Core CLI |
| **BACK-4** | Implement Identity API Endpoints | **Description:** Enable native .NET Identity API endpoints in `Program.cs`.<br>**AC:**<br>- [ ] Endpoints `/register`, `/login`, and `/refresh` are active.<br>- [ ] Verified functionality via Swagger/OpenAPI. | ASP.NET Core |
---
## Phase 1: Authentication & Authorization (UI & Logic)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).<br>**AC:**<br>- [ ] Created a `ProUser` policy.<br>- [ ] Implemented a custom `Requirement` to check if `AITokensUsed < AITokenLimit`. | ASP.NET Core |
| **UI-1** | Implement Login Page (Blazor) | **Description:** Build the Login UI based on the Dark Mode mockup.<br>**AC:**<br>- [ ] Theme: Dark mode with neon green accents.<br>- [ ] Components: Email/Password fields, "Remember Me" toggle, "Login" button.<br>- [ ] Integrates with `AuthenticationStateProvider`. | Blazor / CSS |
| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.<br>**AC:**<br>- [ ] Users can sign in via Google button.<br>- [ ] New users are automatically provisioned in the database upon successful OAuth. | OAuth / Google Cloud |
| **UI-3** | Implement Registration Flow | **Description:** Create a registration form calling the `/register` endpoint.<br>**AC:**<br>- [ ] Validation: Email format, password complexity (min 8 chars, uppercase, digit).<br>- [ ] Proper error handling for existing users. | Blazor |
---
## Phase 2: User Management & SaaS Scaling (Profile & Mobile)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.<br>**AC:**<br>- [ ] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.<br>- [ ] Links to subscription management. | Blazor |
| **MAUI-1** | Mobile Auth Integration (Blazor Hybrid) | **Description:** Ensure the authentication state is shared and persists in the MAUI container.<br>**AC:**<br>- [ ] Securely store JWT tokens in `SecureStorage`.<br>- [ ] Automatic login on app launch if token is valid. | MAUI / Blazor Hybrid |
| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.<br>**AC:**<br>- [ ] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.<br>- [ ] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET |
---
## Definition of Done (DoD)
- All code follows the **NexusArchitect** architectural guidelines.
- Unit tests cover core Identity logic (e.g., token limit validation).
- UI is responsive and consistent with the provided Dark Mode design.
- Documentation updated with setup instructions for new developers.
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.7",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+5 -1
View File
@@ -4,5 +4,9 @@
"path": "." "path": "."
} }
], ],
"settings": {} "settings": {
"dotrush.roslyn.projectOrSolutionFiles": [
"NexusReader.slnx"
]
}
} }
-11
View File
@@ -1,11 +0,0 @@
# Definition of Done (DoD)
1. **Architecture Compliance:** Feature follows CQRS flow. Logic is in Handlers. Result is wrapped in `Result<T>` from FluentResult.
2. **Modularization:** Code is in `/src`, tests in `/tests`. Module-specific logic is isolated.
3. **UI/UX Integrity:** - "Vertical Flow Check" passed (Assistant is part of the document stream, not an absolute pop-up).
- No "Layout Shift" during AI content streaming.
- Safe-area-insets respected for iOS/Android notches.
4. **Code Quality:** C# 14 syntax used (Primary Constructors, etc.). Scoped CSS (.razor.css) implemented.
5. **D3.js Performance:** JS Modules correctly disposed using `IAsyncDisposable`.
6. **Persistence:** State survives manual page refresh (Local/Session Storage integration).
7. **Mapping:** All entity-to-DTO conversions must use Mapster.
-15
View File
@@ -1,15 +0,0 @@
# Agent Personas
## NexusArchitect
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
- **Architecture Role:** Lead Clean Architecture Specialist.
- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator]
- **Technical Constraints:**
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level.
- **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components.
- **Error Handling:** All handlers must return `Result<T>` via `FluentResult`.
- **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper.
- **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance.
- **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code.
- **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css).
@@ -1,9 +0,0 @@
---
name: blazor-hybrid-bridge
description: Standards for cross-platform compatibility (Web & MAUI Hybrid)
---
# Cross-Platform Integration
- **Abstraction:** Implement `IPlatformService` for native features like Haptics or File System.
- **UI Safety:** Support `env(safe-area-inset-...)` for mobile notches.
- **Touch Input:** Use `user-select: none` on interactive nodes to prevent accidental selection.
@@ -1,9 +0,0 @@
---
name: blazor-state-performance
description: Performance & State Persistence in Blazor .NET 10
---
# Performance Rules
- **State Management:** Use `PersistentComponentState` to sync data between prerendering and client-side.
- **Optimization:** Use `@key` directive for list iterations to minimize DOM diffing.
- **Memory:** Always implement `IAsyncDisposable` in components using JS Interop to prevent memory leaks.
@@ -1,17 +0,0 @@
---
name: nexus-clean-architecture
description: Clean Architecture & CQRS implementation for .NET 10
---
# Clean Architecture Standards
- **Folder Hierarchy:** Root must contain `/src` and `/tests`. Group logic by Feature (e.g., `src/Features/Reader/Queries/GetChapterContent`).
- **CQRS Flow:**
- UI triggers `IMediator.Send()`.
- Handler executes logic and returns `FluentResult.Result`.
- No direct Database/API calls from Razor components.
- **MediatR:** Use `LuckyPennySoftware.MediatR` for implementation.
- **Mapster Integration:**
- Centralize mapping configurations.
- No AutoMapper allowed.
- **Functional Error Handling:**
- Mandatory use of `FluentResult`. No exceptions for business logic flow.
-11
View File
@@ -1,11 +0,0 @@
---
name: nexus-graph-d3
description: D3.js standards for Knowledge Graph
---
# D3.js Standards
- **Data Exchange:** Use `System.Text.Json` with CamelCase naming.
- **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.
- **Events:** JS emits events (like `nodeClicked`) caught by Blazor via `DotNetObjectReference`.
@@ -1,14 +0,0 @@
---
name: nexus-ui-engine
description: Design System & Component rules for Blazor
---
# UI Standards
- **Atomic Components:** Build reusable `Atoms`, `Molecules`, and `Organisms`.
- **Styling:** Scoped CSS only (`.razor.css`). Global styles reserved for Design Tokens.
- **Branding (Nexus Neon):**
- BG: `#121212` (Dark Mode).
- Accent: `#00ff99` (Neon Green).
- Typography: Serif for reading, Sans-Serif for AI interface.
- **Vertical Flow:** AI Assistant must be injected into the document flow, pushing text down smoothly.
- **A11y:** 44x44px touch targets; contrast ratio 4.5:1.
@@ -1,10 +0,0 @@
---
name: semantic-kernel-orchestrator
description: Integrating AI logic with .NET Semantic Kernel
---
# AI Implementation Rules
- **Kernel Setup:** Use Microsoft Semantic Kernel with .NET 10.
- **Function Calling:** Define C# Plugins for "Graph Update" and "Quiz Generation".
- **Streaming:** Implement `IAsyncEnumerable<string>` for real-time assistant responses in the UI.
- **Context:** Ensure chapter metadata is passed as Semantic Memory.
-87
View File
@@ -1,87 +0,0 @@
# 📑 Project Backlog: Nexus AI E-Reader (Mockup Implementation)
**Architecture Framework:** .NET 10 | Blazor Component Model | CQRS with MediatR | FluentResult | Mapster
---
## 🟢 PHASE 1: Infrastructure & Design Tokens
*Goal: Prepare the clean architecture foundation and the visual DNA.*
### [TASK-01] Solution & Directory Structure Setup
- **Action:** Create the folder structure: `/src` for projects, `/tests` for unit/integration tests.
- **Details:** Initialize `NexusReader.Web` (Blazor), `NexusReader.Application`, `NexusReader.Domain`, and `NexusReader.Infrastructure`.
- **DoD:** Solution compiles with strict folder separation.
### [TASK-02] Core Library Integration
- **Action:** Install and configure LuckyPennySoftware.MediatR, Mapster, and FluentResult.
- **Details:** - Setup `MappingConfig` for Mapster in the Application layer.
- Implement a `BaseHandler` that returns `Result<T>`.
- **DoD:** A sample Query returns a `Success` Result via MediatR.
### [TASK-03] Nexus Neon Design System
- **Action:** Implement global CSS variables in `app.css` and base Atoms.
- **Details:** - Variables: `--nexus-neon: #00ff99`, `--nexus-bg: #121212`, `--nexus-card: #1e1e1e`.
- Components: `NexusButton.razor`, `NexusTypography.razor` (handling Serif for ebook, Sans for UI).
- **DoD:** Variables are accessible via all scoped CSS files.
---
## 🔵 PHASE 2: Seamless Reader & AI Assistant (Left Side / Inline)
*Goal: Implement the ebook reading logic and the "Vertical Flow" AI injection.*
### [TASK-04] ReaderCanvas & Dynamic Content Injection
- **Action:** Create `ReaderCanvas.razor` to render ebook text.
- **Details:** - Logic to split text into blocks.
- Implementation of an "Injection Point" system where `AiAssistantBubble.razor` can be rendered inline between paragraphs.
- **Mockup Match:** Text must use the high-contrast Serif font from the mockup.
### [TASK-05] AiAssistantBubble Component
- **Action:** Implement the AI chat bubble with a robot avatar.
- **Details:** - Scoped CSS for the glowing border and dark glassmorphism background.
- Parameters for `DialogueText` and `ActionButtons` ("Pokaż więcej", "Rozwiąż quiz").
- Integration with Semantic Kernel for streaming text.
- **DoD:** Bubble appears smoothly in the text flow without absolute positioning.
---
## 🟡 PHASE 3: Knowledge Graph & Brain (Right Side / Flow)
*Goal: Implement the D3.js graph and the "You are here" logic.*
### [TASK-06] D3.js Knowledge Graph Bridge
- **Action:** Implement `KnowledgeGraph.razor` with JS Interop.
- **Details:** - ES6 Module `knowledgeGraph.js` using D3.js v7.
- SVG ViewBox scaling for portrait orientation.
- Implementation of the "TU JESTEŚ" (You Are Here) pulsing label on the active node.
- **DoD:** Clicking a node in JS triggers a C# EventCallback via `DotNetObjectReference`.
### [TASK-07] Semantic Mapping Service
- **Action:** Create the `GetKnowledgeGraphQuery` (CQRS).
- **Details:** - Service uses Semantic Kernel to extract nodes from the current chapter.
- Mapster maps the AI raw response to the `GraphViewModel`.
- **DoD:** Graph updates dynamically when the reader moves to a new chapter.
---
## 🟠 PHASE 4: Verification & Mobile Polish
*Goal: Implement the quiz module and cross-platform readiness.*
### [TASK-08] KnowledgeCheck (Quiz) Module
- **Action:** Implement the `SubmitAnswerCommand` using MediatR.
- **Details:** - UI: `KnowledgeCheck.razor` with radio buttons and a "Wyślij" (Submit) button.
- Logic: Handler returns `Result` (Success/Failure) via FluentResult.
- Mapster: Map `QuizDto` to `QuizViewModel`.
- **Mockup Match:** Neon highlight on the selected/correct answer.
### [TASK-09] Persistence & Cross-Platform (Hybrid)
- **Action:** Implement `IPlatformService` for Android/iOS support.
- **Details:** - Safe-area-insets implementation for notches.
- `BrowserStorage` implementation of `AppState` to save progress.
- Haptic feedback abstraction (trigger vibration on correct answer).
- **DoD:** App maintains graph state after a manual refresh.
---
## 🛠️ Instructions for NexusArchitect
1. **Vertical Flow Priority:** Ensure that the AI assistant and the Graph never overlay text. Use `Flexbox` or `Grid` for a single, continuous scrollable column in portrait mode.
2. **Result Pattern:** Every single Mediator Handler **must** return `FluentResults.Result`.
3. **Mapster:** Perform all DTO-to-UI mappings in the Query/Command Handlers, not in the Razor components.
4. **Isolated Styles:** All specific CSS for the Neon effect must be in `.razor.css` files.
-11
View File
@@ -1,11 +0,0 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/NexusReader.Application/NexusReader.Application.csproj" />
<Project Path="src/NexusReader.Domain/NexusReader.Domain.csproj" />
<Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" />
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" />
</Folder>
<Folder Name="/src/NexusReader.Web.New/">
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" />
</Folder>
</Solution>
-8
View File
@@ -1,8 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}
@@ -1,12 +0,0 @@
using FluentResults;
using MediatR;
namespace NexusReader.Application.Abstractions.Messaging;
public interface ICommand : IRequest<Result>
{
}
public interface ICommand<TResponse> : IRequest<Result<TResponse>>
{
}
@@ -1,14 +0,0 @@
using FluentResults;
using MediatR;
namespace NexusReader.Application.Abstractions.Messaging;
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
where TCommand : ICommand
{
}
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse>
{
}
@@ -1,8 +0,0 @@
using FluentResults;
using MediatR;
namespace NexusReader.Application.Abstractions.Messaging;
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}
@@ -1,9 +0,0 @@
using FluentResults;
using MediatR;
namespace NexusReader.Application.Abstractions.Messaging;
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse>
{
}
@@ -1,9 +0,0 @@
using FluentResults;
using NexusReader.Application.Queries.Quiz;
namespace NexusReader.Application.Abstractions.Services;
public interface IAiGenerateQuizService
{
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
}
@@ -1,6 +0,0 @@
namespace NexusReader.Application.Abstractions.Services;
public interface IPlatformService
{
Task VibrateAsync(int milliseconds);
}
@@ -1,5 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Commands.Quiz;
public record SubmitAnswerCommand(int SelectedIndex, int CorrectIndex) : ICommand;
@@ -1,26 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Commands.Quiz;
internal sealed class SubmitAnswerCommandHandler : ICommandHandler<SubmitAnswerCommand>
{
private readonly IPlatformService _platformService;
public SubmitAnswerCommandHandler(IPlatformService platformService)
{
_platformService = platformService;
}
public async Task<Result> Handle(SubmitAnswerCommand request, CancellationToken cancellationToken)
{
if (request.SelectedIndex == request.CorrectIndex)
{
await _platformService.VibrateAsync(50);
return Result.Ok();
}
return Result.Fail("Incorrect answer.");
}
}
@@ -1,19 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Mappings;
namespace NexusReader.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddMapsterConfiguration();
services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
return services;
}
}
@@ -1,22 +0,0 @@
using Mapster;
using MapsterMapper;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace NexusReader.Application.Mappings;
public static class MappingConfig
{
public static IServiceCollection AddMapsterConfiguration(this IServiceCollection services)
{
var config = TypeAdapterConfig.GlobalSettings;
// Scan assembly and register mappings
config.Scan(Assembly.GetExecutingAssembly());
services.AddSingleton(config);
services.AddScoped<IMapper, ServiceMapper>();
return services;
}
}
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="Mapster" Version="10.0.7" />
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageReference Include="MediatR" Version="12.1.1" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,5 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Graph;
public record GetKnowledgeGraphQuery : IQuery<GraphDataDto>;
@@ -1,30 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Graph;
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
{
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
{
var nodes = new List<GraphNodeDto>
{
new("renesans-intro", "Renesans", "Concept"),
new("florencja", "Florencja", "Location"),
new("medyceusze", "Medyceusze", "Entity"),
new("da-vinci-ai", "Leonardo da Vinci", "Person"),
new("humanizm", "Humanizm", "Concept")
};
var links = new List<GraphLinkDto>
{
new("renesans-intro", "florencja", 1),
new("florencja", "medyceusze", 2),
new("medyceusze", "da-vinci-ai", 3),
new("renesans-intro", "humanizm", 1),
new("da-vinci-ai", "humanizm", 2)
};
return Task.FromResult(Result.Ok(new GraphDataDto(nodes, links)));
}
}
@@ -1,5 +0,0 @@
namespace NexusReader.Application.Queries.Graph;
public record GraphNodeDto(string Id, string Label, string Group);
public record GraphLinkDto(string Source, string Target, int Value);
public record GraphDataDto(List<GraphNodeDto> Nodes, List<GraphLinkDto> Links);
@@ -1,5 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Quiz;
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
@@ -1,20 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Queries.Quiz;
internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler<GetQuizQuestionsQuery, QuizDto>
{
private readonly IAiGenerateQuizService _aiService;
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
{
_aiService = aiService;
}
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
{
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
}
}
@@ -1,4 +0,0 @@
namespace NexusReader.Application.Queries.Quiz;
public record QuizQuestionDto(string Question, List<string> Options, int CorrectIndex);
public record QuizDto(List<QuizQuestionDto> Questions);
@@ -1,5 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Reader;
public record GetReaderPageQuery : IQuery<ReaderPageViewModel>;
@@ -1,20 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Reader;
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
{
public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
{
var blocks = new List<ContentBlock>
{
new TextSegmentBlock("renesans-intro", "Renesans, nazywany również odrodzeniem, to epoka w historii kultury europejskiej, która zapoczątkowała odejście od średniowiecznego teocentryzmu na rzecz humanizmu. Narodził się we Włoszech, a dokładnie we Florencji, w XV wieku, skąd promieniował na całą Europę."),
new TextSegmentBlock("medyceusze", "Głównym mecenasem sztuki i nauki we Florencji był potężny ród Medyceuszy. To dzięki ich wsparciu miasto stało się kolebką nowożytnej myśli, gromadząc wokół siebie najwybitniejsze umysły tamtych czasów."),
new AiActionTriggerBlock("da-vinci-ai", "Leonardo da Vinci był jednym z najważniejszych twórców tego okresu. Czy chciałbyś dowiedzieć się więcej o jego najważniejszych wynalazkach, czy wolisz sprawdzić swoją dotychczasową wiedzę?", new List<string> { "Pokaż więcej", "Rozwiąż quiz" }),
new TextSegmentBlock("leonardo-detail", "Człowiek renesansu, uosabiany właśnie przez Leonarda, był wszechstronnie wykształcony. Interesował się sztuką, inżynierią, anatomią i filozofią, stawiając jednostkę w centrum wszechświata.")
};
return Task.FromResult(Result.Ok(new ReaderPageViewModel(blocks)));
}
}
@@ -1,7 +0,0 @@
namespace NexusReader.Application.Queries.Reader;
public abstract record ContentBlock(string Id);
public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id);
public record AiActionTriggerBlock(string Id, string Dialogue, List<string> ActionOptions) : ContentBlock(Id);
public record ReaderPageViewModel(List<ContentBlock> Blocks);
@@ -1,5 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.System;
public record GetInitializationStatusQuery : IQuery<string>;
@@ -1,12 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.System;
internal sealed class GetInitializationStatusQueryHandler : IQueryHandler<GetInitializationStatusQuery, string>
{
public Task<Result<string>> Handle(GetInitializationStatusQuery request, CancellationToken cancellationToken)
{
return Task.FromResult(Result.Ok("Nexus E-Reader Application is fully initialized and operational."));
}
}
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,14 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Services;
namespace NexusReader.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
return services;
}
}
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,23 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Quiz;
namespace NexusReader.Infrastructure.Services;
public sealed class FakeAiGenerateQuizService : IAiGenerateQuizService
{
public async Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default)
{
// 2000ms delay to highlight Skeleton loader visually
await Task.Delay(2000, cancellationToken);
var fakeQuiz = new List<QuizQuestionDto>
{
new("Co było głównym centrum włoskiego Renesansu?", new List<string> { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2),
new("Kto stanowił wpływowy ród mecenasów sztuki?", new List<string> { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1),
new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List<string> { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2)
};
return Result.Ok(new QuizDto(fakeQuiz));
}
}
@@ -1,11 +0,0 @@
<button class="nexus-btn @Class" @onclick="OnClick" disabled="@Disabled" @attributes="AdditionalAttributes">
@ChildContent
</button>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string Class { get; set; } = string.Empty;
[Parameter] public EventCallback<Microsoft.AspNetCore.Components.Web.MouseEventArgs> OnClick { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
}
@@ -1,35 +0,0 @@
.nexus-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0.5rem 1rem;
background-color: var(--nexus-card);
color: var(--nexus-neon);
border: 1px solid var(--nexus-neon);
font-family: var(--nexus-font-sans);
font-weight: 500;
font-size: 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 5px rgba(0, 255, 153, 0.1);
}
.nexus-btn:hover:not(:disabled) {
background-color: rgba(0, 255, 153, 0.1);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.3);
}
.nexus-btn:active:not(:disabled) {
transform: scale(0.98);
}
.nexus-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: #555;
color: #555;
box-shadow: none;
}
@@ -1,25 +0,0 @@
<svg class="nexus-icon @Class" viewBox="0 0 24 24" fill="currentColor" width="@Size" height="@Size" @attributes="AdditionalAttributes">
@switch (Name.ToLowerInvariant())
{
case "robot":
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
break;
case "play":
<path d="M8 5v14l11-7z" />
break;
case "check":
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
break;
default:
<!-- Fallback circle -->
<circle cx="12" cy="12" r="10" />
break;
}
</svg>
@code {
[Parameter] public string Name { get; set; } = string.Empty;
[Parameter] public string Size { get; set; } = "24";
[Parameter] public string Class { get; set; } = string.Empty;
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
}

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,10 +0,0 @@
.nexus-icon {
display: inline-block;
vertical-align: middle;
transition: fill 0.2s ease, filter 0.2s ease;
}
.neon-glow {
fill: var(--nexus-neon);
filter: drop-shadow(0 0 4px var(--nexus-neon));
}
@@ -1,25 +0,0 @@
<div class="nexus-typography @VariantCssClass @Class" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string Class { get; set; } = string.Empty;
[Parameter] public TypographyVariant Variant { get; set; } = TypographyVariant.UI;
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
private string VariantCssClass => Variant switch
{
TypographyVariant.Heading => "nexus-heading",
TypographyVariant.Ebook => "nexus-ebook",
TypographyVariant.UI => "nexus-ui",
_ => "nexus-ui"
};
public enum TypographyVariant
{
Heading,
Ebook,
UI
}
}
@@ -1,25 +0,0 @@
.nexus-typography {
margin: 0;
}
.nexus-heading {
font-family: var(--nexus-font-sans);
font-size: 2rem;
font-weight: 600;
color: var(--nexus-text);
margin-bottom: 1rem;
}
.nexus-ebook {
font-family: var(--nexus-font-serif);
font-size: 1.125rem;
line-height: 1.8;
color: var(--nexus-text);
margin-bottom: 1.2rem;
}
.nexus-ui {
font-family: var(--nexus-font-sans);
font-size: 1rem;
color: var(--nexus-text);
}
@@ -1,44 +0,0 @@
@using NexusReader.Web.Client.Services
@inject IQuizStateService QuizState
<div class="ai-bubble">
<div class="ai-avatar">
<NexusIcon Name="robot" Size="32" Class="neon-glow" />
</div>
<div class="ai-content">
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Dialogue</NexusTypography>
@if (Actions != null && Actions.Any())
{
<div class="ai-actions">
@foreach (var action in Actions)
{
<NexusButton OnClick="() => HandleActionClick(action)" Disabled="@_isQuizMode">@action</NexusButton>
}
</div>
}
</div>
</div>
@code {
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
[Parameter] public string Dialogue { get; set; } = string.Empty;
[Parameter] public List<string> Actions { get; set; } = new();
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
private bool _isQuizMode = false;
private async Task HandleActionClick(string action)
{
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
{
_isQuizMode = true;
QuizState.RequestQuiz(ContextBlockId);
}
if (OnActionTriggered.HasDelegate)
{
await OnActionTriggered.InvokeAsync(action);
}
}
}
@@ -1,33 +0,0 @@
.ai-bubble {
display: flex;
flex-direction: row;
gap: 1rem;
padding: 1.5rem;
background: rgba(30, 30, 30, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 255, 153, 0.2);
border-left: 3px solid var(--nexus-neon);
border-radius: 8px;
box-shadow: -2px 0 10px rgba(0, 255, 153, 0.4);
}
.ai-avatar {
flex-shrink: 0;
display: flex;
align-items: flex-start;
padding-top: 0.2rem;
}
.ai-content {
display: flex;
flex-direction: column;
gap: 1rem;
flex-grow: 1;
}
.ai-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.8rem;
}
@@ -1,84 +0,0 @@
@using MediatR
@using NexusReader.Application.Queries.Quiz
@using NexusReader.Application.Commands.Quiz
@inject IMediator Mediator
<div class="knowledge-check-container">
@if (_isLoading)
{
<div class="skeleton-loader">
<div class="shimmer"></div>
</div>
}
else if (_quiz != null)
{
@foreach (var question in _quiz.Questions)
{
<div class="quiz-block @GetBlockClass(question)">
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@question.Question</NexusTypography>
<div class="options-container">
@for (int i = 0; i < question.Options.Count; i++)
{
var index = i;
<button class="quiz-option @GetOptionClass(question, index)"
@onclick="() => SelectOptionAsync(question, index)"
disabled="@_states.ContainsKey(question)">
@question.Options[index]
</button>
}
</div>
</div>
}
}
</div>
@code {
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
private bool _isLoading = true;
private QuizDto? _quiz;
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
protected override async Task OnInitializedAsync()
{
_isLoading = true;
var query = new GetQuizQuestionsQuery(ContextBlockId);
var result = await Mediator.Send(query);
if (result.IsSuccess)
_quiz = result.Value;
_isLoading = false;
}
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
{
if (_states.ContainsKey(question)) return;
var cmd = new SubmitAnswerCommand(index, question.CorrectIndex);
var res = await Mediator.Send(cmd);
_states[question] = (index, res.IsSuccess);
}
private string GetBlockClass(QuizQuestionDto question)
{
if (!_states.TryGetValue(question, out var state)) return "";
return state.IsCorrect ? "state-correct" : "state-incorrect";
}
private string GetOptionClass(QuizQuestionDto question, int index)
{
if (!_states.TryGetValue(question, out var state)) return "";
if (state.SelectedIndex == index)
return state.IsCorrect ? "option-correct" : "option-incorrect";
if (state.IsCorrect == false && question.CorrectIndex == index)
return "option-revealed-correct";
return "option-faded";
}
}
@@ -1,107 +0,0 @@
.knowledge-check-container {
width: 100%;
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.skeleton-loader {
width: 100%;
height: 120px;
background-color: var(--nexus-card);
border-radius: 8px;
position: relative;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.05);
}
.skeleton-loader .shimmer {
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
animation: loadingShimmer 1.5s infinite ease-in-out;
}
@keyframes loadingShimmer {
100% { left: 200%; }
}
.quiz-block {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background-color: var(--nexus-card);
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
transition: all 0.3s ease;
}
.quiz-block.state-correct {
border-color: var(--nexus-neon);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.1);
}
.quiz-block.state-incorrect {
border-color: rgba(255, 60, 60, 0.6);
animation: shakePulse 0.4s ease-in-out;
}
@keyframes shakePulse {
0% { transform: translateX(0); }
25% { transform: translateX(-4px); box-shadow: -4px 0 10px rgba(255,60,60,0.3); }
50% { transform: translateX(4px); box-shadow: 4px 0 10px rgba(255,60,60,0.3); }
75% { transform: translateX(-4px); box-shadow: -4px 0 10px rgba(255,60,60,0.3); }
100% { transform: translateX(0); }
}
.options-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.quiz-option {
padding: 0.8rem;
background-color: var(--nexus-card);
border: 1px solid rgba(255,255,255,0.1);
color: var(--nexus-text);
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
font-family: var(--nexus-font-sans);
}
.quiz-option:hover:not(:disabled) {
background-color: rgba(255,255,255,0.05);
}
.quiz-option:disabled {
cursor: default;
}
.option-correct {
background-color: rgba(0, 255, 153, 0.15) !important;
border-color: var(--nexus-neon) !important;
color: white;
}
.option-incorrect {
background-color: rgba(255, 60, 60, 0.15) !important;
border-color: #ff3c3c !important;
color: white;
}
.option-revealed-correct {
border-color: var(--nexus-neon) !important;
border-style: dashed;
}
.option-faded {
opacity: 0.5;
}
@@ -1,98 +0,0 @@
@using MediatR
@using NexusReader.Application.Queries.Graph
@using Microsoft.JSInterop
@using NexusReader.Web.Client.Services
@implements IAsyncDisposable
@inject IMediator Mediator
@inject IJSRuntime JS
@inject IFocusModeService FocusMode
<div class="knowledge-graph-container" id="@ContainerId">
@if (GraphData == null)
{
<div class="loading-state">
<NexusIcon Name="robot" Size="48" Class="neon-glow" />
<NexusTypography>Analyzing Chapter Nodes...</NexusTypography>
</div>
}
</div>
@code {
[Parameter] public EventCallback<string> OnNodeSelected { get; set; }
private string ContainerId = "d3-graph-container";
private GraphDataDto? GraphData;
private IJSObjectReference? _module;
private DotNetObjectReference<KnowledgeGraph>? _dotNetHelper;
protected override void OnInitialized()
{
FocusMode.OnFocusModeChanged += HandleFocusSimulation;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var result = await Mediator.Send(new GetKnowledgeGraphQuery());
if (result.IsSuccess)
{
GraphData = result.Value;
StateHasChanged();
await InitializeGraphAsync();
}
}
}
private async Task InitializeGraphAsync()
{
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/knowledgeGraph.js");
_dotNetHelper = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("mount", ContainerId, GraphData, _dotNetHelper);
}
[JSInvokable]
public async Task OnNodeClicked(string nodeId)
{
if (OnNodeSelected.HasDelegate)
{
await OnNodeSelected.InvokeAsync(nodeId);
}
}
private async void HandleFocusSimulation()
{
if (_module == null) return;
try
{
if (FocusMode.IsFocusModeActive)
await _module.InvokeVoidAsync("pause");
else
await _module.InvokeVoidAsync("resume");
}
catch { }
}
public async ValueTask DisposeAsync()
{
FocusMode.OnFocusModeChanged -= HandleFocusSimulation;
try
{
if (_module is not null)
{
await _module.InvokeVoidAsync("unmount", ContainerId);
await _module.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
// Ignored, the circuit is already closed
}
catch (TaskCanceledException)
{
// Ignored, the circuit is already closed
}
_dotNetHelper?.Dispose();
}
}
@@ -1,30 +0,0 @@
.knowledge-graph-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
overflow: hidden;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
animation: pulse 2s infinite ease-in-out;
}
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
::deep .nexus-node-active {
stroke: var(--nexus-neon) !important;
stroke-width: 4px !important;
filter: drop-shadow(0 0 8px var(--nexus-neon));
transition: all 0.3s ease;
}
@@ -1,86 +0,0 @@
@using MediatR
@using NexusReader.Application.Queries.Reader
@using Microsoft.JSInterop
@using NexusReader.Web.Client.Services
@implements IDisposable
@inject IMediator Mediator
@inject IJSRuntime JS
@inject IThemeService ThemeService
@inject IFocusModeService FocusMode
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "")" style="padding: 2rem 0;">
<div style="display: flex; justify-content: flex-end; align-items: center; margin-bottom: 2rem; gap: 1rem; padding: 0 2rem;">
<NexusButton OnClick="@(_ => ThemeService.ToggleTheme())">
@(ThemeService.IsLightMode ? "Turn Off Lights" : "Turn On Lights")
</NexusButton>
<button @onclick="FocusMode.ToggleAsync" title="Focus Mode (F)" style="background:none; border:none; cursor:pointer; padding: 0;">
<NexusIcon Name="target" Size="28" Class="@(FocusMode.IsFocusModeActive ? "neon-glow" : "")" />
</button>
</div>
@if (ViewModel == null)
{
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@StatusMessage</NexusTypography>
}
else
{
<div class="reader-flow-container">
@foreach (var block in ViewModel.Blocks)
{
<div id="@block.Id" class="block-wrapper">
@if (block is TextSegmentBlock textSegment)
{
<NexusTypography Variant="NexusTypography.TypographyVariant.Ebook">@textSegment.Content</NexusTypography>
}
else if (block is AiActionTriggerBlock aiTrigger)
{
<AiAssistantBubble
ContextBlockId="@block.Id"
Dialogue="@aiTrigger.Dialogue"
Actions="@aiTrigger.ActionOptions"
OnActionTriggered="HandleAiAction" />
}
</div>
}
</div>
}
</div>
@code {
private ReaderPageViewModel? ViewModel;
private string StatusMessage = "Loading chapter...";
protected override async Task OnInitializedAsync()
{
ThemeService.OnThemeChanged += StateHasChanged;
var result = await Mediator.Send(new GetReaderPageQuery());
if (result.IsSuccess)
{
ViewModel = result.Value;
}
else
{
StatusMessage = "Failed to load chapter content.";
}
}
private void HandleAiAction(string action)
{
Console.WriteLine($"Action Triggered from Bubble: {action}");
}
public async Task ScrollToNodeAsync(string id)
{
try
{
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
}
catch { }
}
public void Dispose()
{
ThemeService.OnThemeChanged -= StateHasChanged;
}
}
@@ -1,12 +0,0 @@
.reader-canvas {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.reader-flow-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@@ -1,10 +0,0 @@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@@ -1,20 +0,0 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -1,31 +0,0 @@
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
@@ -1,157 +0,0 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -1,63 +0,0 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -1,91 +0,0 @@
@page "/"
@using NexusReader.Web.Client.Services
@implements IAsyncDisposable
@inject IQuizStateService QuizState
@inject IFocusModeService FocusMode
@inject IJSRuntime JS
<PageTitle>Nexus E-Reader</PageTitle>
<div class="split-layout @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
<div class="reader-pane">
<ReaderCanvas @ref="readerCanvas" />
</div>
<div class="graph-pane">
<div class="graph-section">
<KnowledgeGraph OnNodeSelected="HandleNodeSelected" />
</div>
@if (!string.IsNullOrEmpty(_activeQuizBlockId))
{
<div class="quiz-section">
<KnowledgeCheck ContextBlockId="@_activeQuizBlockId" />
</div>
}
</div>
</div>
@code {
private ReaderCanvas? readerCanvas;
private string? _activeQuizBlockId;
private IJSObjectReference? _interopModule;
private IJSObjectReference? _keydownHandler;
private DotNetObjectReference<Home>? _dotNetRef;
protected override async Task OnInitializedAsync()
{
QuizState.OnQuizRequested += HandleQuizRequested;
FocusMode.OnFocusModeChanged += StateHasChanged;
await FocusMode.InitializeAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try {
_interopModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/focusInterop.js");
_dotNetRef = DotNetObjectReference.Create(this);
_keydownHandler = await _interopModule.InvokeAsync<IJSObjectReference>("attachKeyboardListener", _dotNetRef);
} catch { } /* ignored dynamically */
}
}
[JSInvokable]
public async Task OnFocusKeypressed()
{
await FocusMode.ToggleAsync();
StateHasChanged();
}
private async Task HandleNodeSelected(string nodeId)
{
if (readerCanvas != null)
{
await readerCanvas.ScrollToNodeAsync(nodeId);
}
}
private void HandleQuizRequested(string blockId)
{
_activeQuizBlockId = blockId;
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
QuizState.OnQuizRequested -= HandleQuizRequested;
FocusMode.OnFocusModeChanged -= StateHasChanged;
if (_interopModule != null && _keydownHandler != null)
{
try {
await _interopModule.InvokeVoidAsync("detachKeyboardListener", _keydownHandler);
await _interopModule.DisposeAsync();
await _keydownHandler.DisposeAsync();
} catch { } // Circuit disconnected catch explicitly
}
_dotNetRef?.Dispose();
}
}
@@ -1,55 +0,0 @@
.split-layout {
display: flex;
flex-direction: row;
width: 100%;
height: 100vh;
}
.reader-pane {
flex: 1;
overflow-y: auto;
padding: 0 2rem;
transition: all 0.3s ease;
}
.graph-pane {
flex: 1;
background: linear-gradient(135deg, #121212 0%, #1a1a1a 100%);
border-left: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
height: 100vh;
display: flex;
flex-direction: column;
overflow-y: auto;
max-width: 50%;
opacity: 1;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.split-layout.focus-mode-active .graph-pane {
flex: 0;
max-width: 0;
opacity: 0;
border-color: transparent;
padding: 0;
}
.graph-section {
width: 100%;
min-height: 50vh;
flex-shrink: 0;
}
.quiz-section {
padding: 0 2rem 2rem 2rem;
display: flex;
flex-direction: column;
}
.reader-pane::-webkit-scrollbar {
width: 6px;
}
.reader-pane::-webkit-scrollbar-thumb {
background-color: var(--nexus-card);
border-radius: 4px;
}
@@ -1,5 +0,0 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
@@ -1,19 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services;
using NexusReader.Application.Queries.Quiz;
using NexusReader.Application;
using NexusReader.Infrastructure;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure();
await builder.Build().RunAsync();
@@ -1,6 +0,0 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -1,44 +0,0 @@
using Microsoft.JSInterop;
namespace NexusReader.Web.Client.Services;
public sealed class FocusModeService : IFocusModeService
{
private readonly IJSRuntime _jsRuntime;
public bool IsFocusModeActive { get; private set; }
public event Action? OnFocusModeChanged;
public FocusModeService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task InitializeAsync()
{
try
{
var value = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "nexus_focus_mode");
if (value == "true" && !IsFocusModeActive)
{
IsFocusModeActive = true;
OnFocusModeChanged?.Invoke();
}
}
catch
{
// Ignored during pre-rendering or unsupported environments
}
}
public async Task ToggleAsync()
{
IsFocusModeActive = !IsFocusModeActive;
OnFocusModeChanged?.Invoke();
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "nexus_focus_mode", IsFocusModeActive ? "true" : "false");
}
catch { }
}
}
@@ -1,9 +0,0 @@
namespace NexusReader.Web.Client.Services;
public interface IFocusModeService
{
bool IsFocusModeActive { get; }
event Action? OnFocusModeChanged;
Task InitializeAsync();
Task ToggleAsync();
}
@@ -1,8 +0,0 @@
namespace NexusReader.Web.Client.Services;
public interface IQuizStateService
{
string? CurrentQuizBlockId { get; }
event Action<string>? OnQuizRequested;
void RequestQuiz(string blockId);
}
@@ -1,8 +0,0 @@
namespace NexusReader.Web.Client.Services;
public interface IThemeService
{
bool IsLightMode { get; }
event Action? OnThemeChanged;
void ToggleTheme();
}
@@ -1,13 +0,0 @@
namespace NexusReader.Web.Client.Services;
public sealed class QuizStateService : IQuizStateService
{
public string? CurrentQuizBlockId { get; private set; }
public event Action<string>? OnQuizRequested;
public void RequestQuiz(string blockId)
{
CurrentQuizBlockId = blockId;
OnQuizRequested?.Invoke(blockId);
}
}
@@ -1,13 +0,0 @@
namespace NexusReader.Web.Client.Services;
public sealed class ThemeService : IThemeService
{
public bool IsLightMode { get; private set; } = false;
public event Action? OnThemeChanged;
public void ToggleTheme()
{
IsLightMode = !IsLightMode;
OnThemeChanged?.Invoke();
}
}
@@ -1,26 +0,0 @@
using Microsoft.JSInterop;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Client.Services;
public sealed class WebPlatformService : IPlatformService
{
private readonly IJSRuntime _jsRuntime;
public WebPlatformService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task VibrateAsync(int milliseconds)
{
try
{
await _jsRuntime.InvokeVoidAsync("navigator.vibrate", milliseconds);
}
catch
{
// Ignore on platforms without vibration
}
}
}
@@ -1,13 +0,0 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using NexusReader.Web.Client
@using NexusReader.Web.Client.Layout
@using NexusReader.Web.Client.Components.Atoms
@using NexusReader.Web.Client.Components.Molecules
@using NexusReader.Web.Client.Components.Organisms
@@ -1,20 +0,0 @@
export function attachKeyboardListener(dotNetHelper) {
const handler = (e) => {
// Exclude inputs, textareas, etc.
const activeNode = document.activeElement ? document.activeElement.nodeName.toLowerCase() : '';
if (activeNode === 'input' || activeNode === 'textarea') return;
if (e.key === 'f' || e.key === 'F') {
dotNetHelper.invokeMethodAsync('OnFocusKeypressed');
}
};
window.addEventListener('keydown', handler);
return handler;
}
export function detachKeyboardListener(handler) {
if (handler) {
window.removeEventListener('keydown', handler);
}
}
@@ -1,150 +0,0 @@
import * as d3 from 'https://esm.sh/d3@7';
let simulation;
export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId);
if (!container) return;
const width = container.clientWidth || 400;
const height = container.clientHeight || 400;
// Create SVG
const svg = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", "100%")
.attr("height", "100%");
// Radial gradient for Nebula effect
const defs = svg.append("defs");
const radialGradient = defs.append("radialGradient")
.attr("id", "nebulaGlow")
.attr("cx", "50%")
.attr("cy", "50%")
.attr("r", "50%");
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
// Root Group for Zoom
const rootGroup = svg.append("g").attr("class", "zoom-containment");
// Attach Zoom Behavior
const zoom = d3.zoom()
.scaleExtent([0.5, 4])
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
svg.call(zoom);
// Subtle Link Distance & Charge
simulation = d3.forceSimulation(data.nodes)
.force("link", d3.forceLink(data.links).id(d => d.id).distance(60))
.force("charge", d3.forceManyBody().strength(-150))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(25));
// Links
const link = rootGroup.append("g")
.selectAll("line")
.data(data.links)
.join("line")
.attr("stroke", "#444")
.attr("stroke-opacity", 0.6)
.attr("stroke-width", 1.5);
// Nodes
const node = rootGroup.append("g")
.selectAll("g")
.data(data.nodes)
.join("g")
.style("cursor", "pointer")
.on("click", (e, d) => {
// Remove active state from all, add to clicked
node.select("circle.node-core").classed("nexus-node-active", false);
d3.select(e.currentTarget).select("circle.node-core").classed("nexus-node-active", true);
dotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
})
.call(drag(simulation));
// Outer glow for nodes
node.append("circle")
.attr("r", 14)
.attr("fill", "url(#nebulaGlow)")
.attr("opacity", 0.4);
// Core circle
node.append("circle")
.attr("class", "node-core")
.attr("r", 6)
.attr("fill", "#888")
.attr("stroke", "#222")
.attr("stroke-width", 2);
// Labels
node.append("text")
.text(d => d.label)
.attr("x", 12)
.attr("y", 4)
.attr("fill", "#ccc")
.attr("font-family", "var(--nexus-font-sans)")
.attr("font-size", "0.75rem");
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
}
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
export function unmount(containerId) {
if (simulation) {
simulation.stop();
}
const container = document.getElementById(containerId);
if (container) {
container.innerHTML = ''; // clear svg
}
}
export function scrollToNode(id) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
export function pause() {
if (simulation) {
simulation.stop();
}
}
export function resume() {
if (simulation) {
// give it a gentle kick to settle if moved
simulation.alphaTarget(0.1).restart();
}
}
@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["NexusReader.Web.styles.css"]" />
<ImportMap />
<HeadOutlet @rendermode="InteractiveAuto" />
</head>
<body>
<Routes @rendermode="InteractiveAuto" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@@ -1,36 +0,0 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -1,13 +0,0 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using NexusReader.Web
@using NexusReader.Web.Client
@using NexusReader.Web.Client.Layout
@using NexusReader.Web.Components
@using NexusReader.Web.Client.Components.Atoms
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -1,50 +0,0 @@
using NexusReader.Web.Components;
using NexusReader.Application;
using NexusReader.Infrastructure;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseStaticFiles();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(NexusReader.Web.Client._Imports).Assembly);
app.Run();
@@ -1,25 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5104",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7131;http://localhost:5104",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -1,70 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&display=swap');
:root {
--nexus-neon: #00ff99;
--nexus-bg: #121212;
--nexus-card: #1e1e1e;
--nexus-text: #ffffff;
--nexus-font-sans: 'Inter', sans-serif;
--nexus-font-serif: 'Merriweather', serif;
}
.reader-canvas.theme-light {
--nexus-bg: #F5F5F5;
--nexus-card: #FFFFFF;
--nexus-text: #1A1A1A;
}
body {
background-color: var(--nexus-bg);
color: var(--nexus-text);
font-family: var(--nexus-font-sans);
margin: 0;
padding: 0;
transition: background-color 0.4s ease, color 0.4s ease;
}
.reader-canvas.theme-light {
background-color: var(--nexus-bg);
color: var(--nexus-text);
transition: background-color 0.4s ease, color 0.4s ease;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
@@ -9,4 +9,8 @@ public interface INativeStorageService
Result SaveBool(string key, bool value); Result SaveBool(string key, bool value);
Result<bool> GetBool(string key, bool defaultValue = false); Result<bool> GetBool(string key, bool defaultValue = false);
Result Remove(string key); Result Remove(string key);
Task<Result> SaveSecureString(string key, string value);
Task<Result<string?>> GetSecureString(string key);
Result RemoveSecure(string key);
} }
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" /> <ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
@@ -9,6 +9,8 @@
<PackageReference Include="Mapster" Version="10.0.7" /> <PackageReference Include="Mapster" Version="10.0.7" />
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" /> <PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageReference Include="MediatR" Version="12.1.1" /> <PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -0,0 +1,47 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Security.Authorization;
public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
{
private readonly UserManager<NexusUser> _userManager;
public ProUserHandler(UserManager<NexusUser> userManager)
{
_userManager = userManager;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ProUserRequirement requirement)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return;
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return;
}
// Rule 1: Explicit Pro plan
if (user.CurrentPlan == "Pro")
{
context.Succeed(requirement);
return;
}
// Rule 2: Within Token Limits (SaaS logic)
if (user.AITokensUsed < user.AITokenLimit)
{
context.Succeed(requirement);
return;
}
}
}
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization;
namespace NexusReader.Application.Security.Authorization;
/// <summary>
/// Requirement for users with active "Pro" subscriptions or sufficient AI tokens.
/// </summary>
public class ProUserRequirement : IAuthorizationRequirement
{
}
+36
View File
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Represents an E-book uploaded or owned by a user.
/// </summary>
public class Ebook
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(255)]
public string Title { get; set; } = string.Empty;
[MaxLength(255)]
public string Author { get; set; } = "Unknown";
[Required]
public string FilePath { get; set; } = string.Empty;
public string? CoverUrl { get; set; }
public DateTime AddedDate { get; set; } = DateTime.UtcNow;
public DateTime? LastReadDate { get; set; }
// Relationship to NexusUser
[Required]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public NexusUser? User { get; set; }
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Identity;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Extended Identity user for the Nexus AI E-Reader SaaS platform.
/// </summary>
public class NexusUser : IdentityUser
{
/// <summary>
/// Total number of AI tokens allowed for the current billing period.
/// </summary>
public int AITokenLimit { get; set; }
/// <summary>
/// Number of AI tokens consumed in the current billing period.
/// </summary>
public int AITokensUsed { get; set; }
/// <summary>
/// Unique identifier for the tenant (SaaS multi-tenancy support).
/// </summary>
public Guid TenantId { get; set; }
/// <summary>
/// Current subscription plan (e.g., "Free", "Pro", "Enterprise").
/// </summary>
public string CurrentPlan { get; set; } = "Free";
/// <summary>
/// Collection of e-books owned by the user.
/// </summary>
public ICollection<Ebook> Ebooks { get; set; } = new List<Ebook>();
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -6,4 +6,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
</ItemGroup>
</Project> </Project>
@@ -68,4 +68,42 @@ public sealed class MauiStorageService : INativeStorageService
return Result.Fail(ex.Message); return Result.Fail(ex.Message);
} }
} }
public async Task<Result> SaveSecureString(string key, string value)
{
try
{
await SecureStorage.Default.SetAsync(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result<string?>> GetSecureString(string key)
{
try
{
return Result.Ok(await SecureStorage.Default.GetAsync(key));
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result RemoveSecure(string key)
{
try
{
SecureStorage.Default.Remove(key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
} }
@@ -10,6 +10,10 @@ using NexusReader.Infrastructure.Services;
using NexusReader.Infrastructure.Configuration; using NexusReader.Infrastructure.Configuration;
using Polly; using Polly;
using Polly.Retry; using Polly.Retry;
using NexusReader.Domain.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authorization;
using NexusReader.Application.Security.Authorization;
namespace NexusReader.Infrastructure; namespace NexusReader.Infrastructure;
@@ -53,6 +57,14 @@ public static class DependencyInjection
services.AddScoped<IKnowledgeService, KnowledgeService>(); services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>(); services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
services.AddTransient<IEpubService, EpubService>(); services.AddTransient<IEpubService, EpubService>();
services.AddAuthorizationCore(options =>
{
options.AddPolicy("ProUser", policy => policy.Requirements.Add(new ProUserRequirement()));
});
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
return services; return services;
} }
} }
@@ -0,0 +1,45 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Persistence;
namespace NexusReader.Infrastructure.Identity;
/// <summary>
/// Handler that validates if the user has available AI tokens.
/// </summary>
public class TokenLimitHandler : AuthorizationHandler<TokenLimitRequirement>
{
private readonly AppDbContext _dbContext;
private readonly UserManager<NexusUser> _userManager;
public TokenLimitHandler(AppDbContext dbContext, UserManager<NexusUser> userManager)
{
_dbContext = dbContext;
_userManager = userManager;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
TokenLimitRequirement requirement)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
{
return;
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return;
}
// Check if user has available tokens
if (user.AITokensUsed < user.AITokenLimit)
{
context.Succeed(requirement);
}
}
}
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization;
namespace NexusReader.Infrastructure.Identity;
/// <summary>
/// Requirement to check if a user has not exceeded their AI token limit.
/// </summary>
public class TokenLimitRequirement : IAuthorizationRequirement
{
}
@@ -0,0 +1,368 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260428142027_InitialIdentityAndEbooks")]
partial class InitialIdentityAndEbooks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
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("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
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");
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");
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("TEXT");
b.Property<DateTime>("AddedDate")
.HasColumnType("TEXT");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
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>("CurrentPlan")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<Guid>("TenantId")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("TEXT");
b.HasKey("ContentHash");
b.HasIndex("ContentHash")
.IsUnique();
b.ToTable("SemanticKnowledgeCache");
});
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.NexusUser", b =>
{
b.Navigation("Ebooks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,282 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialIdentityAndEbooks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
AITokenLimit = table.Column<int>(type: "INTEGER", nullable: false),
AITokensUsed = table.Column<int>(type: "INTEGER", nullable: false),
TenantId = table.Column<Guid>(type: "TEXT", nullable: false),
CurrentPlan = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SemanticKnowledgeCache",
columns: table => new
{
ContentHash = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
JsonData = table.Column<string>(type: "TEXT", nullable: false),
ModelId = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
PromptVersion = table.Column<string>(type: "TEXT", maxLength: 10, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SemanticKnowledgeCache", x => x.ContentHash);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Ebooks",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
Author = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
FilePath = table.Column<string>(type: "TEXT", nullable: false),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
AddedDate = table.Column<DateTime>(type: "TEXT", nullable: false),
LastReadDate = table.Column<DateTime>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Ebooks", x => x.Id);
table.ForeignKey(
name: "FK_Ebooks_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Ebooks_UserId",
table: "Ebooks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SemanticKnowledgeCache_ContentHash",
table: "SemanticKnowledgeCache",
column: "ContentHash",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "Ebooks");
migrationBuilder.DropTable(
name: "SemanticKnowledgeCache");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

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