From 10efed03696f2f0ff2bd36301e8143118c781c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 28 Apr 2026 20:23:40 +0200 Subject: [PATCH] refactor: consolidate project structure by migrating authentication, identity, and shared UI components while removing legacy Web Client files. --- .agents/agents.md | 2 +- .agents/skills/nexus-identity-saas.md | 39 ++ Directory.Build.props | 4 +- backlog-identity.md | 45 +++ dotnet-tools.json | 13 + ejajBook.code-workspace | 6 +- ejajBook/.agents/DOD.md | 11 - ejajBook/.agents/agents.md | 15 - .../.agents/skills/blazor-hybrid-bridge.md | 9 - .../skills/blazor-state-performance.md | 9 - .../skills/nexus-clean-architecture.md | 17 - ejajBook/.agents/skills/nexus-graph-d3.md | 11 - ejajBook/.agents/skills/nexus-ui-engine.md | 14 - .../skills/semantic-kernel-orchestrator.md | 10 - ejajBook/BACKLOG.md | 87 ----- ejajBook/NexusReader.slnx | 11 - ejajBook/ejajBook.code-workspace | 8 - .../Abstractions/Messaging/ICommand.cs | 12 - .../Abstractions/Messaging/ICommandHandler.cs | 14 - .../Abstractions/Messaging/IQuery.cs | 8 - .../Abstractions/Messaging/IQueryHandler.cs | 9 - .../Services/IAiGenerateQuizService.cs | 9 - .../Abstractions/Services/IPlatformService.cs | 6 - .../Commands/Quiz/SubmitAnswerCommand.cs | 5 - .../Quiz/SubmitAnswerCommandHandler.cs | 26 -- .../DependencyInjection.cs | 19 - .../Mappings/MappingConfig.cs | 22 -- .../NexusReader.Application.csproj | 20 - .../Queries/Graph/GetKnowledgeGraphQuery.cs | 5 - .../Graph/GetKnowledgeGraphQueryHandler.cs | 30 -- .../Queries/Graph/GraphViewModels.cs | 5 - .../Queries/Quiz/GetQuizQuestionsQuery.cs | 5 - .../Quiz/GetQuizQuestionsQueryHandler.cs | 20 - .../Queries/Quiz/QuizViewModels.cs | 4 - .../Queries/Reader/GetReaderPageQuery.cs | 5 - .../Reader/GetReaderPageQueryHandler.cs | 20 - .../Queries/Reader/ViewModels.cs | 7 - .../System/GetInitializationStatusQuery.cs | 5 - .../GetInitializationStatusQueryHandler.cs | 12 - .../NexusReader.Domain.csproj | 9 - .../DependencyInjection.cs | 14 - .../NexusReader.Infrastructure.csproj | 13 - .../Services/FakeAiGenerateQuizService.cs | 23 -- .../Components/Atoms/NexusButton.razor | 11 - .../Components/Atoms/NexusButton.razor.css | 35 -- .../Components/Atoms/NexusIcon.razor | 25 -- .../Components/Atoms/NexusIcon.razor.css | 10 - .../Components/Atoms/NexusTypography.razor | 25 -- .../Atoms/NexusTypography.razor.css | 25 -- .../Molecules/AiAssistantBubble.razor | 44 --- .../Molecules/AiAssistantBubble.razor.css | 33 -- .../Components/Molecules/KnowledgeCheck.razor | 84 ---- .../Molecules/KnowledgeCheck.razor.css | 107 ----- .../Components/Organisms/KnowledgeGraph.razor | 98 ----- .../Organisms/KnowledgeGraph.razor.css | 30 -- .../Components/Organisms/ReaderCanvas.razor | 86 ---- .../Organisms/ReaderCanvas.razor.css | 12 - .../Layout/MainLayout.razor | 10 - .../Layout/MainLayout.razor.css | 20 - .../Layout/ReconnectModal.razor | 31 -- .../Layout/ReconnectModal.razor.css | 157 -------- .../Layout/ReconnectModal.razor.js | 63 --- .../NexusReader.Web.Client.csproj | 22 -- .../NexusReader.Web.Client/Pages/Home.razor | 91 ----- .../Pages/Home.razor.css | 55 --- .../Pages/NotFound.razor | 5 - .../src/NexusReader.Web.Client/Program.cs | 19 - .../src/NexusReader.Web.Client/Routes.razor | 6 - .../Services/FocusModeService.cs | 44 --- .../Services/IFocusModeService.cs | 9 - .../Services/IQuizStateService.cs | 8 - .../Services/IThemeService.cs | 8 - .../Services/QuizStateService.cs | 13 - .../Services/ThemeService.cs | 13 - .../Services/WebPlatformService.cs | 26 -- .../src/NexusReader.Web.Client/_Imports.razor | 13 - .../wwwroot/js/focusInterop.js | 20 - .../wwwroot/js/knowledgeGraph.js | 150 ------- .../NexusReader.Web.New/Components/App.razor | 21 - .../Components/Pages/Error.razor | 36 -- .../Components/_Imports.razor | 13 - .../NexusReader.Web.csproj | 20 - ejajBook/src/NexusReader.Web.New/Program.cs | 50 --- .../Properties/launchSettings.json | 25 -- .../appsettings.Development.json | 8 - .../src/NexusReader.Web.New/appsettings.json | 9 - .../src/NexusReader.Web.New/wwwroot/app.css | 70 ---- .../Services/INativeStorageService.cs | 4 + .../NexusReader.Application.csproj | 4 +- .../Security/Authorization/ProUserHandler.cs | 47 +++ .../Authorization/ProUserRequirement.cs | 10 + src/NexusReader.Domain/Entities/Ebook.cs | 36 ++ src/NexusReader.Domain/Entities/NexusUser.cs | 34 ++ .../NexusReader.Domain.csproj | 6 +- .../Services/MauiStorageService.cs | 38 ++ .../DependencyInjection.cs | 12 + .../Identity/TokenLimitHandler.cs | 45 +++ .../Identity/TokenLimitRequirement.cs | 10 + ...42027_InitialIdentityAndEbooks.Designer.cs | 368 ++++++++++++++++++ ...20260428142027_InitialIdentityAndEbooks.cs | 282 ++++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 365 +++++++++++++++++ .../NexusReader.Infrastructure.csproj | 22 +- .../Persistence/AppDbContext.cs | 12 +- src/NexusReader.Maui/MauiProgram.cs | 11 + .../Layout/MainLayout.razor | 27 +- .../NexusReader.UI.Shared.csproj | 1 + .../Pages/Account/Login.razor | 120 ++++++ .../Pages/Account/Login.razor.css | 223 +++++++++++ .../Pages/Account/Profile.razor | 79 ++++ .../Pages/Account/Profile.razor.css | 173 ++++++++ .../Pages/Account/Register.razor | 112 ++++++ .../Pages/Account/Register.razor.css | 174 +++++++++ src/NexusReader.UI.Shared/Routes.razor | 6 +- .../Services/IdentityService.cs | 87 +++++ .../NexusAuthenticationStateProvider.cs | 81 ++++ src/NexusReader.UI.Shared/_Imports.razor | 1 + .../NexusReader.Web.Client.csproj | 2 +- .../Controllers/StripeWebhookController.cs | 97 +++++ .../NexusReader.Web.csproj | 11 +- src/NexusReader.Web.New/Program.cs | 140 ++++++- .../Services/WebStorageService.cs | 91 +++++ src/NexusReader.Web.New/appsettings.json | 6 + src/NexusReader.Web.New/nexus.db-shm | Bin 0 -> 32768 bytes src/NexusReader.Web.New/nexus.db-wal | Bin 0 -> 37112 bytes 124 files changed, 2822 insertions(+), 2213 deletions(-) create mode 100644 .agents/skills/nexus-identity-saas.md create mode 100644 backlog-identity.md create mode 100644 dotnet-tools.json delete mode 100644 ejajBook/.agents/DOD.md delete mode 100644 ejajBook/.agents/agents.md delete mode 100644 ejajBook/.agents/skills/blazor-hybrid-bridge.md delete mode 100644 ejajBook/.agents/skills/blazor-state-performance.md delete mode 100644 ejajBook/.agents/skills/nexus-clean-architecture.md delete mode 100644 ejajBook/.agents/skills/nexus-graph-d3.md delete mode 100644 ejajBook/.agents/skills/nexus-ui-engine.md delete mode 100644 ejajBook/.agents/skills/semantic-kernel-orchestrator.md delete mode 100644 ejajBook/BACKLOG.md delete mode 100644 ejajBook/NexusReader.slnx delete mode 100644 ejajBook/ejajBook.code-workspace delete mode 100644 ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommand.cs delete mode 100644 ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommandHandler.cs delete mode 100644 ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQuery.cs delete mode 100644 ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQueryHandler.cs delete mode 100644 ejajBook/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs delete mode 100644 ejajBook/src/NexusReader.Application/Abstractions/Services/IPlatformService.cs delete mode 100644 ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommand.cs delete mode 100644 ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommandHandler.cs delete mode 100644 ejajBook/src/NexusReader.Application/DependencyInjection.cs delete mode 100644 ejajBook/src/NexusReader.Application/Mappings/MappingConfig.cs delete mode 100644 ejajBook/src/NexusReader.Application/NexusReader.Application.csproj delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Quiz/QuizViewModels.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/Reader/ViewModels.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQuery.cs delete mode 100644 ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQueryHandler.cs delete mode 100644 ejajBook/src/NexusReader.Domain/NexusReader.Domain.csproj delete mode 100644 ejajBook/src/NexusReader.Infrastructure/DependencyInjection.cs delete mode 100644 ejajBook/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj delete mode 100644 ejajBook/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.js delete mode 100644 ejajBook/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj delete mode 100644 ejajBook/src/NexusReader.Web.Client/Pages/Home.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Pages/Home.razor.css delete mode 100644 ejajBook/src/NexusReader.Web.Client/Pages/NotFound.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Program.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Routes.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/Services/FocusModeService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Services/IFocusModeService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Services/IQuizStateService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Services/IThemeService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Services/QuizStateService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Services/ThemeService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/Services/WebPlatformService.cs delete mode 100644 ejajBook/src/NexusReader.Web.Client/_Imports.razor delete mode 100644 ejajBook/src/NexusReader.Web.Client/wwwroot/js/focusInterop.js delete mode 100644 ejajBook/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js delete mode 100644 ejajBook/src/NexusReader.Web.New/Components/App.razor delete mode 100644 ejajBook/src/NexusReader.Web.New/Components/Pages/Error.razor delete mode 100644 ejajBook/src/NexusReader.Web.New/Components/_Imports.razor delete mode 100644 ejajBook/src/NexusReader.Web.New/NexusReader.Web.csproj delete mode 100644 ejajBook/src/NexusReader.Web.New/Program.cs delete mode 100644 ejajBook/src/NexusReader.Web.New/Properties/launchSettings.json delete mode 100644 ejajBook/src/NexusReader.Web.New/appsettings.Development.json delete mode 100644 ejajBook/src/NexusReader.Web.New/appsettings.json delete mode 100644 ejajBook/src/NexusReader.Web.New/wwwroot/app.css create mode 100644 src/NexusReader.Application/Security/Authorization/ProUserHandler.cs create mode 100644 src/NexusReader.Application/Security/Authorization/ProUserRequirement.cs create mode 100644 src/NexusReader.Domain/Entities/Ebook.cs create mode 100644 src/NexusReader.Domain/Entities/NexusUser.cs create mode 100644 src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs create mode 100644 src/NexusReader.Infrastructure/Identity/TokenLimitRequirement.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.Designer.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs create mode 100644 src/NexusReader.UI.Shared/Pages/Account/Login.razor create mode 100644 src/NexusReader.UI.Shared/Pages/Account/Login.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/Account/Profile.razor create mode 100644 src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/Account/Register.razor create mode 100644 src/NexusReader.UI.Shared/Pages/Account/Register.razor.css create mode 100644 src/NexusReader.UI.Shared/Services/IdentityService.cs create mode 100644 src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs create mode 100644 src/NexusReader.Web.New/Controllers/StripeWebhookController.cs create mode 100644 src/NexusReader.Web.New/Services/WebStorageService.cs create mode 100644 src/NexusReader.Web.New/nexus.db-shm create mode 100644 src/NexusReader.Web.New/nexus.db-wal diff --git a/.agents/agents.md b/.agents/agents.md index 596860b..22136d3 100644 --- a/.agents/agents.md +++ b/.agents/agents.md @@ -4,7 +4,7 @@ - **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] +- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas] - **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. diff --git a/.agents/skills/nexus-identity-saas.md b/.agents/skills/nexus-identity-saas.md new file mode 100644 index 0000000..3bb58e4 --- /dev/null +++ b/.agents/skills/nexus-identity-saas.md @@ -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` 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` and `UserManager` for custom login logic and user management. + +- **Service Configuration & Policies:** + - Register Identity using `builder.Services.AddDefaultIdentity()` or `AddIdentity()` followed by `.AddEntityFrameworkStores()`. + - 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. diff --git a/Directory.Build.props b/Directory.Build.props index ac9855f..2dda395 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - /home/debian/android-sdk - /home/debian/java/jdk-17.0.10+7 + $(HOME)/android-sdk + /usr/lib/jvm/java-21-openjdk-amd64 diff --git a/backlog-identity.md b/backlog-identity.md new file mode 100644 index 0000000..510b085 --- /dev/null +++ b/backlog-identity.md @@ -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.
**AC:**
- [ ] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).
- [ ] Model placed in `NexusArchitect.Core` project. | C# / Identity | +| **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext`.
**AC:**
- [ ] Mapped standard Identity tables (Users, Roles, Claims).
- [ ] 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.
**AC:**
- [ ] SQL schema contains all 7+ standard Identity tables.
- [ ] 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`.
**AC:**
- [ ] Endpoints `/register`, `/login`, and `/refresh` are active.
- [ ] 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).
**AC:**
- [ ] Created a `ProUser` policy.
- [ ] 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.
**AC:**
- [ ] Theme: Dark mode with neon green accents.
- [ ] Components: Email/Password fields, "Remember Me" toggle, "Login" button.
- [ ] Integrates with `AuthenticationStateProvider`. | Blazor / CSS | +| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.
**AC:**
- [ ] Users can sign in via Google button.
- [ ] 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.
**AC:**
- [ ] Validation: Email format, password complexity (min 8 chars, uppercase, digit).
- [ ] 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.
**AC:**
- [ ] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.
- [ ] 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.
**AC:**
- [ ] Securely store JWT tokens in `SecureStorage`.
- [ ] 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.
**AC:**
- [ ] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.
- [ ] 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. \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..ce0aab7 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.7", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/ejajBook.code-workspace b/ejajBook.code-workspace index 876a149..96055b7 100644 --- a/ejajBook.code-workspace +++ b/ejajBook.code-workspace @@ -4,5 +4,9 @@ "path": "." } ], - "settings": {} + "settings": { + "dotrush.roslyn.projectOrSolutionFiles": [ + "NexusReader.slnx" + ] + } } \ No newline at end of file diff --git a/ejajBook/.agents/DOD.md b/ejajBook/.agents/DOD.md deleted file mode 100644 index 56058c0..0000000 --- a/ejajBook/.agents/DOD.md +++ /dev/null @@ -1,11 +0,0 @@ -# Definition of Done (DoD) - -1. **Architecture Compliance:** Feature follows CQRS flow. Logic is in Handlers. Result is wrapped in `Result` 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. \ No newline at end of file diff --git a/ejajBook/.agents/agents.md b/ejajBook/.agents/agents.md deleted file mode 100644 index 596860b..0000000 --- a/ejajBook/.agents/agents.md +++ /dev/null @@ -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` 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). \ No newline at end of file diff --git a/ejajBook/.agents/skills/blazor-hybrid-bridge.md b/ejajBook/.agents/skills/blazor-hybrid-bridge.md deleted file mode 100644 index 976c040..0000000 --- a/ejajBook/.agents/skills/blazor-hybrid-bridge.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/ejajBook/.agents/skills/blazor-state-performance.md b/ejajBook/.agents/skills/blazor-state-performance.md deleted file mode 100644 index e1989ae..0000000 --- a/ejajBook/.agents/skills/blazor-state-performance.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/ejajBook/.agents/skills/nexus-clean-architecture.md b/ejajBook/.agents/skills/nexus-clean-architecture.md deleted file mode 100644 index d60dd25..0000000 --- a/ejajBook/.agents/skills/nexus-clean-architecture.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/ejajBook/.agents/skills/nexus-graph-d3.md b/ejajBook/.agents/skills/nexus-graph-d3.md deleted file mode 100644 index bc008a1..0000000 --- a/ejajBook/.agents/skills/nexus-graph-d3.md +++ /dev/null @@ -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`. \ No newline at end of file diff --git a/ejajBook/.agents/skills/nexus-ui-engine.md b/ejajBook/.agents/skills/nexus-ui-engine.md deleted file mode 100644 index 882da42..0000000 --- a/ejajBook/.agents/skills/nexus-ui-engine.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/ejajBook/.agents/skills/semantic-kernel-orchestrator.md b/ejajBook/.agents/skills/semantic-kernel-orchestrator.md deleted file mode 100644 index 605f7c5..0000000 --- a/ejajBook/.agents/skills/semantic-kernel-orchestrator.md +++ /dev/null @@ -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` for real-time assistant responses in the UI. -- **Context:** Ensure chapter metadata is passed as Semantic Memory. \ No newline at end of file diff --git a/ejajBook/BACKLOG.md b/ejajBook/BACKLOG.md deleted file mode 100644 index bdc0889..0000000 --- a/ejajBook/BACKLOG.md +++ /dev/null @@ -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`. -- **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. \ No newline at end of file diff --git a/ejajBook/NexusReader.slnx b/ejajBook/NexusReader.slnx deleted file mode 100644 index 35271f2..0000000 --- a/ejajBook/NexusReader.slnx +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/ejajBook/ejajBook.code-workspace b/ejajBook/ejajBook.code-workspace deleted file mode 100644 index 876a149..0000000 --- a/ejajBook/ejajBook.code-workspace +++ /dev/null @@ -1,8 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": {} -} \ No newline at end of file diff --git a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommand.cs b/ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommand.cs deleted file mode 100644 index 507d6b7..0000000 --- a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentResults; -using MediatR; - -namespace NexusReader.Application.Abstractions.Messaging; - -public interface ICommand : IRequest -{ -} - -public interface ICommand : IRequest> -{ -} diff --git a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommandHandler.cs b/ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommandHandler.cs deleted file mode 100644 index bf6b464..0000000 --- a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/ICommandHandler.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentResults; -using MediatR; - -namespace NexusReader.Application.Abstractions.Messaging; - -public interface ICommandHandler : IRequestHandler - where TCommand : ICommand -{ -} - -public interface ICommandHandler : IRequestHandler> - where TCommand : ICommand -{ -} diff --git a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQuery.cs b/ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQuery.cs deleted file mode 100644 index ff09669..0000000 --- a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQuery.cs +++ /dev/null @@ -1,8 +0,0 @@ -using FluentResults; -using MediatR; - -namespace NexusReader.Application.Abstractions.Messaging; - -public interface IQuery : IRequest> -{ -} diff --git a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQueryHandler.cs b/ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQueryHandler.cs deleted file mode 100644 index 3bce270..0000000 --- a/ejajBook/src/NexusReader.Application/Abstractions/Messaging/IQueryHandler.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FluentResults; -using MediatR; - -namespace NexusReader.Application.Abstractions.Messaging; - -public interface IQueryHandler : IRequestHandler> - where TQuery : IQuery -{ -} diff --git a/ejajBook/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs b/ejajBook/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs deleted file mode 100644 index 7a114b8..0000000 --- a/ejajBook/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FluentResults; -using NexusReader.Application.Queries.Quiz; - -namespace NexusReader.Application.Abstractions.Services; - -public interface IAiGenerateQuizService -{ - Task> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default); -} diff --git a/ejajBook/src/NexusReader.Application/Abstractions/Services/IPlatformService.cs b/ejajBook/src/NexusReader.Application/Abstractions/Services/IPlatformService.cs deleted file mode 100644 index 930a3c4..0000000 --- a/ejajBook/src/NexusReader.Application/Abstractions/Services/IPlatformService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace NexusReader.Application.Abstractions.Services; - -public interface IPlatformService -{ - Task VibrateAsync(int milliseconds); -} diff --git a/ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommand.cs b/ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommand.cs deleted file mode 100644 index 59a608e..0000000 --- a/ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Commands.Quiz; - -public record SubmitAnswerCommand(int SelectedIndex, int CorrectIndex) : ICommand; diff --git a/ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommandHandler.cs b/ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommandHandler.cs deleted file mode 100644 index 0f68b07..0000000 --- a/ejajBook/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommandHandler.cs +++ /dev/null @@ -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 -{ - private readonly IPlatformService _platformService; - - public SubmitAnswerCommandHandler(IPlatformService platformService) - { - _platformService = platformService; - } - - public async Task Handle(SubmitAnswerCommand request, CancellationToken cancellationToken) - { - if (request.SelectedIndex == request.CorrectIndex) - { - await _platformService.VibrateAsync(50); - return Result.Ok(); - } - - return Result.Fail("Incorrect answer."); - } -} diff --git a/ejajBook/src/NexusReader.Application/DependencyInjection.cs b/ejajBook/src/NexusReader.Application/DependencyInjection.cs deleted file mode 100644 index f897249..0000000 --- a/ejajBook/src/NexusReader.Application/DependencyInjection.cs +++ /dev/null @@ -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; - } -} diff --git a/ejajBook/src/NexusReader.Application/Mappings/MappingConfig.cs b/ejajBook/src/NexusReader.Application/Mappings/MappingConfig.cs deleted file mode 100644 index aa79ba0..0000000 --- a/ejajBook/src/NexusReader.Application/Mappings/MappingConfig.cs +++ /dev/null @@ -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(); - - return services; - } -} diff --git a/ejajBook/src/NexusReader.Application/NexusReader.Application.csproj b/ejajBook/src/NexusReader.Application/NexusReader.Application.csproj deleted file mode 100644 index 24bfe05..0000000 --- a/ejajBook/src/NexusReader.Application/NexusReader.Application.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs b/ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs deleted file mode 100644 index d95619a..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.Graph; - -public record GetKnowledgeGraphQuery : IQuery; diff --git a/ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs b/ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs deleted file mode 100644 index b8e7e07..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FluentResults; -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.Graph; - -internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler -{ - public Task> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken) - { - var nodes = new List - { - 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 - { - 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))); - } -} diff --git a/ejajBook/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs b/ejajBook/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs deleted file mode 100644 index 00a0ce3..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs +++ /dev/null @@ -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 Nodes, List Links); diff --git a/ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs b/ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs deleted file mode 100644 index 6ae3617..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.Quiz; - -public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery; diff --git a/ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs b/ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs deleted file mode 100644 index e034e7d..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs +++ /dev/null @@ -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 -{ - private readonly IAiGenerateQuizService _aiService; - - public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService) - { - _aiService = aiService; - } - - public async Task> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken) - { - return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken); - } -} diff --git a/ejajBook/src/NexusReader.Application/Queries/Quiz/QuizViewModels.cs b/ejajBook/src/NexusReader.Application/Queries/Quiz/QuizViewModels.cs deleted file mode 100644 index a236c5a..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Quiz/QuizViewModels.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace NexusReader.Application.Queries.Quiz; - -public record QuizQuestionDto(string Question, List Options, int CorrectIndex); -public record QuizDto(List Questions); diff --git a/ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs b/ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs deleted file mode 100644 index b97aa27..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.Reader; - -public record GetReaderPageQuery : IQuery; diff --git a/ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs b/ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs deleted file mode 100644 index 28f040d..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentResults; -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.Reader; - -internal sealed class GetReaderPageQueryHandler : IQueryHandler -{ - public Task> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) - { - var blocks = new List - { - 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 { "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))); - } -} diff --git a/ejajBook/src/NexusReader.Application/Queries/Reader/ViewModels.cs b/ejajBook/src/NexusReader.Application/Queries/Reader/ViewModels.cs deleted file mode 100644 index fb06490..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/Reader/ViewModels.cs +++ /dev/null @@ -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 ActionOptions) : ContentBlock(Id); - -public record ReaderPageViewModel(List Blocks); diff --git a/ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQuery.cs b/ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQuery.cs deleted file mode 100644 index 45f94cf..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.System; - -public record GetInitializationStatusQuery : IQuery; diff --git a/ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQueryHandler.cs b/ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQueryHandler.cs deleted file mode 100644 index cd067e8..0000000 --- a/ejajBook/src/NexusReader.Application/Queries/System/GetInitializationStatusQueryHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentResults; -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.System; - -internal sealed class GetInitializationStatusQueryHandler : IQueryHandler -{ - public Task> Handle(GetInitializationStatusQuery request, CancellationToken cancellationToken) - { - return Task.FromResult(Result.Ok("Nexus E-Reader Application is fully initialized and operational.")); - } -} diff --git a/ejajBook/src/NexusReader.Domain/NexusReader.Domain.csproj b/ejajBook/src/NexusReader.Domain/NexusReader.Domain.csproj deleted file mode 100644 index 6d36c6d..0000000 --- a/ejajBook/src/NexusReader.Domain/NexusReader.Domain.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net10.0 - enable - enable - - - diff --git a/ejajBook/src/NexusReader.Infrastructure/DependencyInjection.cs b/ejajBook/src/NexusReader.Infrastructure/DependencyInjection.cs deleted file mode 100644 index bcb1f70..0000000 --- a/ejajBook/src/NexusReader.Infrastructure/DependencyInjection.cs +++ /dev/null @@ -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(); - return services; - } -} diff --git a/ejajBook/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/ejajBook/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj deleted file mode 100644 index f79590e..0000000 --- a/ejajBook/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - net10.0 - enable - enable - - - diff --git a/ejajBook/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs b/ejajBook/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs deleted file mode 100644 index 22b9d9d..0000000 --- a/ejajBook/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs +++ /dev/null @@ -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> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default) - { - // 2000ms delay to highlight Skeleton loader visually - await Task.Delay(2000, cancellationToken); - - var fakeQuiz = new List - { - new("Co było głównym centrum włoskiego Renesansu?", new List { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2), - new("Kto stanowił wpływowy ród mecenasów sztuki?", new List { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1), - new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2) - }; - - return Result.Ok(new QuizDto(fakeQuiz)); - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor deleted file mode 100644 index dbe84b1..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor +++ /dev/null @@ -1,11 +0,0 @@ - - -@code { - [Parameter] public RenderFragment? ChildContent { get; set; } - [Parameter] public string Class { get; set; } = string.Empty; - [Parameter] public EventCallback OnClick { get; set; } - [Parameter] public bool Disabled { get; set; } - [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor.css deleted file mode 100644 index f8e54d3..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor.css +++ /dev/null @@ -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; -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor deleted file mode 100644 index e687adc..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor +++ /dev/null @@ -1,25 +0,0 @@ - - @switch (Name.ToLowerInvariant()) - { - case "robot": - - break; - case "play": - - break; - case "check": - - break; - default: - - - break; - } - - -@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? AdditionalAttributes { get; set; } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor.css deleted file mode 100644 index 53024c6..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor.css +++ /dev/null @@ -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)); -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor deleted file mode 100644 index 2b3ab32..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor +++ /dev/null @@ -1,25 +0,0 @@ -
- @ChildContent -
- -@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? 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 - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor.css deleted file mode 100644 index 62dfd80..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor.css +++ /dev/null @@ -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); -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor deleted file mode 100644 index 1f5e287..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor +++ /dev/null @@ -1,44 +0,0 @@ -@using NexusReader.Web.Client.Services -@inject IQuizStateService QuizState - -
-
- -
-
- @Dialogue - - @if (Actions != null && Actions.Any()) - { -
- @foreach (var action in Actions) - { - @action - } -
- } -
-
- -@code { - [Parameter] public string ContextBlockId { get; set; } = string.Empty; - [Parameter] public string Dialogue { get; set; } = string.Empty; - [Parameter] public List Actions { get; set; } = new(); - [Parameter] public EventCallback 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); - } - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor.css deleted file mode 100644 index 2d7079c..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor.css +++ /dev/null @@ -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; -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor deleted file mode 100644 index 99fcf49..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor +++ /dev/null @@ -1,84 +0,0 @@ -@using MediatR -@using NexusReader.Application.Queries.Quiz -@using NexusReader.Application.Commands.Quiz -@inject IMediator Mediator - -
- @if (_isLoading) - { -
-
-
- } - else if (_quiz != null) - { - @foreach (var question in _quiz.Questions) - { -
- @question.Question - -
- @for (int i = 0; i < question.Options.Count; i++) - { - var index = i; - - } -
-
- } - } -
- -@code { - [Parameter] public string ContextBlockId { get; set; } = string.Empty; - - private bool _isLoading = true; - private QuizDto? _quiz; - - private Dictionary _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"; - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor.css deleted file mode 100644 index 24cd993..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor.css +++ /dev/null @@ -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; -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor deleted file mode 100644 index 0f13b71..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor +++ /dev/null @@ -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 - -
- @if (GraphData == null) - { -
- - Analyzing Chapter Nodes... -
- } -
- -@code { - [Parameter] public EventCallback OnNodeSelected { get; set; } - - private string ContainerId = "d3-graph-container"; - private GraphDataDto? GraphData; - private IJSObjectReference? _module; - private DotNetObjectReference? _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("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(); - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor.css deleted file mode 100644 index e23804e..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor.css +++ /dev/null @@ -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; -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor deleted file mode 100644 index 4040495..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor +++ /dev/null @@ -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 - -
-
- - @(ThemeService.IsLightMode ? "Turn Off Lights" : "Turn On Lights") - - -
- - @if (ViewModel == null) - { - @StatusMessage - } - else - { -
- @foreach (var block in ViewModel.Blocks) - { -
- @if (block is TextSegmentBlock textSegment) - { - @textSegment.Content - } - else if (block is AiActionTriggerBlock aiTrigger) - { - - } -
- } -
- } -
- -@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; - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor.css deleted file mode 100644 index 053d648..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor.css +++ /dev/null @@ -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; -} diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor b/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor deleted file mode 100644 index 50596c7..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor +++ /dev/null @@ -1,10 +0,0 @@ -@inherits LayoutComponentBase - -@Body - -
- An unhandled error has occurred. - Reload - 🗙 -
- diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor.css b/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor.css deleted file mode 100644 index 3e40fb8..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor.css +++ /dev/null @@ -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; - } diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor deleted file mode 100644 index 11132ba..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor +++ /dev/null @@ -1,31 +0,0 @@ - - - -
- -

- Rejoining the server... -

-

- Rejoin failed... trying again in seconds. -

-

- Failed to rejoin.
Please retry or reload the page. -

- -

- The session has been paused by the server. -

-

- Failed to resume the session.
Please retry or reload the page. -

- -
-
diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.css b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.css deleted file mode 100644 index af38c96..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.css +++ /dev/null @@ -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; - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.js b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.js deleted file mode 100644 index 1609d42..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.js +++ /dev/null @@ -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(); - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj b/ejajBook/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj deleted file mode 100644 index a0edfaf..0000000 --- a/ejajBook/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - true - Default - true - - - - - - - - - - - - - diff --git a/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor b/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor deleted file mode 100644 index e54cc5c..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor +++ /dev/null @@ -1,91 +0,0 @@ -@page "/" -@using NexusReader.Web.Client.Services -@implements IAsyncDisposable -@inject IQuizStateService QuizState -@inject IFocusModeService FocusMode -@inject IJSRuntime JS -Nexus E-Reader - -
-
- -
-
-
- -
- - @if (!string.IsNullOrEmpty(_activeQuizBlockId)) - { -
- -
- } -
-
- -@code { - private ReaderCanvas? readerCanvas; - private string? _activeQuizBlockId; - - private IJSObjectReference? _interopModule; - private IJSObjectReference? _keydownHandler; - private DotNetObjectReference? _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("import", "./js/focusInterop.js"); - _dotNetRef = DotNetObjectReference.Create(this); - _keydownHandler = await _interopModule.InvokeAsync("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(); - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor.css b/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor.css deleted file mode 100644 index cf981ac..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor.css +++ /dev/null @@ -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; -} diff --git a/ejajBook/src/NexusReader.Web.Client/Pages/NotFound.razor b/ejajBook/src/NexusReader.Web.Client/Pages/NotFound.razor deleted file mode 100644 index f74a7a3..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Pages/NotFound.razor +++ /dev/null @@ -1,5 +0,0 @@ -@page "/not-found" -@layout MainLayout - -

Not Found

-

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/ejajBook/src/NexusReader.Web.Client/Program.cs b/ejajBook/src/NexusReader.Web.Client/Program.cs deleted file mode 100644 index 629e80b..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Program.cs +++ /dev/null @@ -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(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddApplication(); -builder.Services.AddInfrastructure(); - -await builder.Build().RunAsync(); diff --git a/ejajBook/src/NexusReader.Web.Client/Routes.razor b/ejajBook/src/NexusReader.Web.Client/Routes.razor deleted file mode 100644 index 9328b04..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/ejajBook/src/NexusReader.Web.Client/Services/FocusModeService.cs b/ejajBook/src/NexusReader.Web.Client/Services/FocusModeService.cs deleted file mode 100644 index 9014ea1..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Services/FocusModeService.cs +++ /dev/null @@ -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("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 { } - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Services/IFocusModeService.cs b/ejajBook/src/NexusReader.Web.Client/Services/IFocusModeService.cs deleted file mode 100644 index dd665a7..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Services/IFocusModeService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NexusReader.Web.Client.Services; - -public interface IFocusModeService -{ - bool IsFocusModeActive { get; } - event Action? OnFocusModeChanged; - Task InitializeAsync(); - Task ToggleAsync(); -} diff --git a/ejajBook/src/NexusReader.Web.Client/Services/IQuizStateService.cs b/ejajBook/src/NexusReader.Web.Client/Services/IQuizStateService.cs deleted file mode 100644 index 29b11a9..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Services/IQuizStateService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NexusReader.Web.Client.Services; - -public interface IQuizStateService -{ - string? CurrentQuizBlockId { get; } - event Action? OnQuizRequested; - void RequestQuiz(string blockId); -} diff --git a/ejajBook/src/NexusReader.Web.Client/Services/IThemeService.cs b/ejajBook/src/NexusReader.Web.Client/Services/IThemeService.cs deleted file mode 100644 index a43387a..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Services/IThemeService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NexusReader.Web.Client.Services; - -public interface IThemeService -{ - bool IsLightMode { get; } - event Action? OnThemeChanged; - void ToggleTheme(); -} diff --git a/ejajBook/src/NexusReader.Web.Client/Services/QuizStateService.cs b/ejajBook/src/NexusReader.Web.Client/Services/QuizStateService.cs deleted file mode 100644 index c659ca7..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Services/QuizStateService.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace NexusReader.Web.Client.Services; - -public sealed class QuizStateService : IQuizStateService -{ - public string? CurrentQuizBlockId { get; private set; } - public event Action? OnQuizRequested; - - public void RequestQuiz(string blockId) - { - CurrentQuizBlockId = blockId; - OnQuizRequested?.Invoke(blockId); - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Services/ThemeService.cs b/ejajBook/src/NexusReader.Web.Client/Services/ThemeService.cs deleted file mode 100644 index b14030d..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Services/ThemeService.cs +++ /dev/null @@ -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(); - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/Services/WebPlatformService.cs b/ejajBook/src/NexusReader.Web.Client/Services/WebPlatformService.cs deleted file mode 100644 index a9ede1c..0000000 --- a/ejajBook/src/NexusReader.Web.Client/Services/WebPlatformService.cs +++ /dev/null @@ -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 - } - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/_Imports.razor b/ejajBook/src/NexusReader.Web.Client/_Imports.razor deleted file mode 100644 index f87b0bb..0000000 --- a/ejajBook/src/NexusReader.Web.Client/_Imports.razor +++ /dev/null @@ -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 diff --git a/ejajBook/src/NexusReader.Web.Client/wwwroot/js/focusInterop.js b/ejajBook/src/NexusReader.Web.Client/wwwroot/js/focusInterop.js deleted file mode 100644 index a33bf2a..0000000 --- a/ejajBook/src/NexusReader.Web.Client/wwwroot/js/focusInterop.js +++ /dev/null @@ -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); - } -} diff --git a/ejajBook/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js b/ejajBook/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js deleted file mode 100644 index 13be344..0000000 --- a/ejajBook/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js +++ /dev/null @@ -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(); - } -} diff --git a/ejajBook/src/NexusReader.Web.New/Components/App.razor b/ejajBook/src/NexusReader.Web.New/Components/App.razor deleted file mode 100644 index e194056..0000000 --- a/ejajBook/src/NexusReader.Web.New/Components/App.razor +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/ejajBook/src/NexusReader.Web.New/Components/Pages/Error.razor b/ejajBook/src/NexusReader.Web.New/Components/Pages/Error.razor deleted file mode 100644 index 7a84043..0000000 --- a/ejajBook/src/NexusReader.Web.New/Components/Pages/Error.razor +++ /dev/null @@ -1,36 +0,0 @@ -@page "/Error" -@using System.Diagnostics - -Error - -

Error.

-

An error occurred while processing your request.

- -@if (ShowRequestId) -{ -

- Request ID: @RequestId -

-} - -

Development Mode

-

- Swapping to Development environment will display more detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

- -@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; -} diff --git a/ejajBook/src/NexusReader.Web.New/Components/_Imports.razor b/ejajBook/src/NexusReader.Web.New/Components/_Imports.razor deleted file mode 100644 index 8a4ee67..0000000 --- a/ejajBook/src/NexusReader.Web.New/Components/_Imports.razor +++ /dev/null @@ -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 diff --git a/ejajBook/src/NexusReader.Web.New/NexusReader.Web.csproj b/ejajBook/src/NexusReader.Web.New/NexusReader.Web.csproj deleted file mode 100644 index 8505eb3..0000000 --- a/ejajBook/src/NexusReader.Web.New/NexusReader.Web.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net10.0 - enable - enable - true - - - - - - - - - - - - - diff --git a/ejajBook/src/NexusReader.Web.New/Program.cs b/ejajBook/src/NexusReader.Web.New/Program.cs deleted file mode 100644 index 5d50947..0000000 --- a/ejajBook/src/NexusReader.Web.New/Program.cs +++ /dev/null @@ -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(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -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() - .AddInteractiveServerRenderMode() - .AddInteractiveWebAssemblyRenderMode() - .AddAdditionalAssemblies(typeof(NexusReader.Web.Client._Imports).Assembly); - -app.Run(); diff --git a/ejajBook/src/NexusReader.Web.New/Properties/launchSettings.json b/ejajBook/src/NexusReader.Web.New/Properties/launchSettings.json deleted file mode 100644 index 384b224..0000000 --- a/ejajBook/src/NexusReader.Web.New/Properties/launchSettings.json +++ /dev/null @@ -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" - } - } - } - } diff --git a/ejajBook/src/NexusReader.Web.New/appsettings.Development.json b/ejajBook/src/NexusReader.Web.New/appsettings.Development.json deleted file mode 100644 index ff66ba6..0000000 --- a/ejajBook/src/NexusReader.Web.New/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/ejajBook/src/NexusReader.Web.New/appsettings.json b/ejajBook/src/NexusReader.Web.New/appsettings.json deleted file mode 100644 index 4d56694..0000000 --- a/ejajBook/src/NexusReader.Web.New/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/ejajBook/src/NexusReader.Web.New/wwwroot/app.css b/ejajBook/src/NexusReader.Web.New/wwwroot/app.css deleted file mode 100644 index f39c635..0000000 --- a/ejajBook/src/NexusReader.Web.New/wwwroot/app.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs index d9a4bff..909853c 100644 --- a/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs +++ b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs @@ -9,4 +9,8 @@ public interface INativeStorageService Result SaveBool(string key, bool value); Result GetBool(string key, bool defaultValue = false); Result Remove(string key); + + Task SaveSecureString(string key, string value); + Task> GetSecureString(string key); + Result RemoveSecure(string key); } diff --git a/src/NexusReader.Application/NexusReader.Application.csproj b/src/NexusReader.Application/NexusReader.Application.csproj index 24bfe05..6393116 100644 --- a/src/NexusReader.Application/NexusReader.Application.csproj +++ b/src/NexusReader.Application/NexusReader.Application.csproj @@ -1,4 +1,4 @@ - + @@ -9,6 +9,8 @@ + + diff --git a/src/NexusReader.Application/Security/Authorization/ProUserHandler.cs b/src/NexusReader.Application/Security/Authorization/ProUserHandler.cs new file mode 100644 index 0000000..4ac94e7 --- /dev/null +++ b/src/NexusReader.Application/Security/Authorization/ProUserHandler.cs @@ -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 +{ + private readonly UserManager _userManager; + + public ProUserHandler(UserManager 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; + } + } +} diff --git a/src/NexusReader.Application/Security/Authorization/ProUserRequirement.cs b/src/NexusReader.Application/Security/Authorization/ProUserRequirement.cs new file mode 100644 index 0000000..62b8371 --- /dev/null +++ b/src/NexusReader.Application/Security/Authorization/ProUserRequirement.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; + +namespace NexusReader.Application.Security.Authorization; + +/// +/// Requirement for users with active "Pro" subscriptions or sufficient AI tokens. +/// +public class ProUserRequirement : IAuthorizationRequirement +{ +} diff --git a/src/NexusReader.Domain/Entities/Ebook.cs b/src/NexusReader.Domain/Entities/Ebook.cs new file mode 100644 index 0000000..2c1315e --- /dev/null +++ b/src/NexusReader.Domain/Entities/Ebook.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace NexusReader.Domain.Entities; + +/// +/// Represents an E-book uploaded or owned by a user. +/// +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; } +} diff --git a/src/NexusReader.Domain/Entities/NexusUser.cs b/src/NexusReader.Domain/Entities/NexusUser.cs new file mode 100644 index 0000000..d698555 --- /dev/null +++ b/src/NexusReader.Domain/Entities/NexusUser.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Identity; + +namespace NexusReader.Domain.Entities; + +/// +/// Extended Identity user for the Nexus AI E-Reader SaaS platform. +/// +public class NexusUser : IdentityUser +{ + /// + /// Total number of AI tokens allowed for the current billing period. + /// + public int AITokenLimit { get; set; } + + /// + /// Number of AI tokens consumed in the current billing period. + /// + public int AITokensUsed { get; set; } + + /// + /// Unique identifier for the tenant (SaaS multi-tenancy support). + /// + public Guid TenantId { get; set; } + + /// + /// Current subscription plan (e.g., "Free", "Pro", "Enterprise"). + /// + public string CurrentPlan { get; set; } = "Free"; + + /// + /// Collection of e-books owned by the user. + /// + public ICollection Ebooks { get; set; } = new List(); +} diff --git a/src/NexusReader.Domain/NexusReader.Domain.csproj b/src/NexusReader.Domain/NexusReader.Domain.csproj index 6d36c6d..c61f30e 100644 --- a/src/NexusReader.Domain/NexusReader.Domain.csproj +++ b/src/NexusReader.Domain/NexusReader.Domain.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs b/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs index c46fa8e..ed532f7 100644 --- a/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs +++ b/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs @@ -68,4 +68,42 @@ public sealed class MauiStorageService : INativeStorageService return Result.Fail(ex.Message); } } + + public async Task 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> 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); + } + } } diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 021ca0c..acf4bd5 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -10,6 +10,10 @@ using NexusReader.Infrastructure.Services; using NexusReader.Infrastructure.Configuration; using Polly; using Polly.Retry; +using NexusReader.Domain.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Authorization; +using NexusReader.Application.Security.Authorization; namespace NexusReader.Infrastructure; @@ -53,6 +57,14 @@ public static class DependencyInjection services.AddScoped(); services.AddTransient(); services.AddTransient(); + + services.AddAuthorizationCore(options => + { + options.AddPolicy("ProUser", policy => policy.Requirements.Add(new ProUserRequirement())); + }); + + services.AddScoped(); + return services; } } diff --git a/src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs b/src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs new file mode 100644 index 0000000..cdb5278 --- /dev/null +++ b/src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs @@ -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; + +/// +/// Handler that validates if the user has available AI tokens. +/// +public class TokenLimitHandler : AuthorizationHandler +{ + private readonly AppDbContext _dbContext; + private readonly UserManager _userManager; + + public TokenLimitHandler(AppDbContext dbContext, UserManager 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); + } + } +} diff --git a/src/NexusReader.Infrastructure/Identity/TokenLimitRequirement.cs b/src/NexusReader.Infrastructure/Identity/TokenLimitRequirement.cs new file mode 100644 index 0000000..ba7eb4e --- /dev/null +++ b/src/NexusReader.Infrastructure/Identity/TokenLimitRequirement.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; + +namespace NexusReader.Infrastructure.Identity; + +/// +/// Requirement to check if a user has not exceeded their AI token limit. +/// +public class TokenLimitRequirement : IAuthorizationRequirement +{ +} diff --git a/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.Designer.cs b/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.Designer.cs new file mode 100644 index 0000000..cfcc443 --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.Designer.cs @@ -0,0 +1,368 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedDate") + .HasColumnType("TEXT"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastReadDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AITokenLimit") + .HasColumnType("INTEGER"); + + b.Property("AITokensUsed") + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentPlan") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("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("ContentHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("ContentHash"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("SemanticKnowledgeCache"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.cs b/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.cs new file mode 100644 index 0000000..27ef8da --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.cs @@ -0,0 +1,282 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Infrastructure.Migrations +{ + /// + public partial class InitialIdentityAndEbooks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AITokenLimit = table.Column(type: "INTEGER", nullable: false), + AITokensUsed = table.Column(type: "INTEGER", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + CurrentPlan = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SemanticKnowledgeCache", + columns: table => new + { + ContentHash = table.Column(type: "TEXT", maxLength: 64, nullable: false), + JsonData = table.Column(type: "TEXT", nullable: false), + ModelId = table.Column(type: "TEXT", maxLength: 50, nullable: false), + PromptVersion = table.Column(type: "TEXT", maxLength: 10, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SemanticKnowledgeCache", x => x.ContentHash); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(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(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(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(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(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(type: "TEXT", nullable: false), + RoleId = table.Column(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(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Value = table.Column(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(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Author = table.Column(type: "TEXT", maxLength: 255, nullable: false), + FilePath = table.Column(type: "TEXT", nullable: false), + CoverUrl = table.Column(type: "TEXT", nullable: true), + AddedDate = table.Column(type: "TEXT", nullable: false), + LastReadDate = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..84969d4 --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,365 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusReader.Infrastructure.Persistence; + +#nullable disable + +namespace NexusReader.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedDate") + .HasColumnType("TEXT"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastReadDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AITokenLimit") + .HasColumnType("INTEGER"); + + b.Property("AITokensUsed") + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentPlan") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("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("ContentHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("ContentHash"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("SemanticKnowledgeCache"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index 423f29f..d388937 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -1,4 +1,4 @@ - + @@ -6,6 +6,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -16,12 +18,12 @@ - - - - net10.0 - enable - enable - - - + + + + net10.0 + enable + enable + + + diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs index f2a7416..4ecd582 100644 --- a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs +++ b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs @@ -1,15 +1,17 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using NexusReader.Domain.Entities; namespace NexusReader.Infrastructure.Persistence; -public class AppDbContext : DbContext +public class AppDbContext : IdentityDbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet SemanticKnowledgeCache => Set(); + public DbSet Ebooks => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -20,5 +22,13 @@ public class AppDbContext : DbContext entity.HasKey(e => e.ContentHash); entity.HasIndex(e => e.ContentHash).IsUnique(); }); + + modelBuilder.Entity(entity => + { + entity.HasOne(e => e.User) + .WithMany(u => u.Ebooks) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index 6282e5d..4fd5782 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -28,6 +28,17 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Identity + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => + sp.GetRequiredService()); + builder.Services.AddCascadingAuthenticationState(); + builder.Services.AddAuthorizationCore(); + + // Network + builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost:5000") }); // Update with real API URL later + // Shared UI State builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor b/src/NexusReader.UI.Shared/Layout/MainLayout.razor index 8fe2dd1..6392142 100644 --- a/src/NexusReader.UI.Shared/Layout/MainLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainLayout.razor @@ -7,6 +7,8 @@ @inject IFocusModeService FocusMode @inject IQuizStateService QuizService @inject IJSRuntime JS +@inject IIdentityService IdentityService +@inject NavigationManager NavigationManager @implements IDisposable
@@ -23,8 +25,23 @@
- - Asystent AI i Interaktywna Mapa +
+ + Asystent AI +
+ + + + + + + + + +
@@ -61,6 +78,12 @@ } } + private async Task HandleLogout() + { + await IdentityService.LogoutAsync(); + NavigationManager.NavigateTo("/", true); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) diff --git a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj index d588858..7dcc1a6 100644 --- a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj +++ b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj @@ -8,6 +8,7 @@ + diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor new file mode 100644 index 0000000..4006e4a --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -0,0 +1,120 @@ +@page "/account/login" +@using Microsoft.AspNetCore.Components.Forms +@using NexusReader.UI.Shared.Services +@inject IIdentityService IdentityService +@inject NavigationManager NavigationManager + + + +@code { + private LoginModel _loginModel = new(); + private string? _errorMessage; + private bool _isSubmitting; + + private async Task HandleLogin() + { + _isSubmitting = true; + _errorMessage = null; + + try + { + var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password); + if (success) + { + NavigationManager.NavigateTo("/"); + } + else + { + _errorMessage = "Invalid email or password."; + } + } + catch (Exception) + { + _errorMessage = "An error occurred during login. Please try again."; + } + finally + { + _isSubmitting = false; + } + } + + private void HandleGoogleLogin() + { + // Redirect to external login endpoint + NavigationManager.NavigateTo("identity/login/google", forceLoad: true); + } + + public class LoginModel + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Email { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } = string.Empty; + + public bool RememberMe { get; set; } + } +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor.css b/src/NexusReader.UI.Shared/Pages/Account/Login.razor.css new file mode 100644 index 0000000..5d19de9 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor.css @@ -0,0 +1,223 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: #0a0a0a; + background-image: radial-gradient(circle at 50% 50%, #1a1a1a 0%, #0a0a0a 100%); + font-family: 'Inter', sans-serif; + color: #e0e0e0; +} + +.login-card { + width: 100%; + max-width: 400px; + padding: 2.5rem; + background: rgba(20, 20, 20, 0.8); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 1.5rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); +} + +.login-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.login-header h1 { + font-size: 2.5rem; + font-weight: 800; + letter-spacing: -0.05em; + margin: 0; + color: #ffffff; +} + +.login-header h1 span { + color: #39ff14; /* Neon Green */ + text-shadow: 0 0 10px rgba(57, 255, 20, 0.5); +} + +.login-header p { + color: #888; + margin-top: 0.5rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: #bbb; +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + background: #151515; + border: 1px solid #2a2a2a; + border-radius: 0.75rem; + color: #fff; + font-size: 1rem; + transition: all 0.2s ease; +} + +.form-control:focus { + outline: none; + border-color: #39ff14; + box-shadow: 0 0 0 4px rgba(57, 255, 20, 0.1); +} + +.form-options { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; + margin-bottom: 2rem; +} + +.remember-me { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.remember-me input { + accent-color: #39ff14; +} + +.forgot-link { + color: #888; + text-decoration: none; + transition: color 0.2s; +} + +.forgot-link:hover { + color: #39ff14; +} + +.btn-login { + width: 100%; + padding: 0.875rem; + background: #39ff14; + color: #000; + border: none; + border-radius: 0.75rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + justify-content: center; + align-items: center; +} + +.btn-login:hover { + transform: translateY(-2px); + box-shadow: 0 0 20px rgba(57, 255, 20, 0.4); + background: #32e612; +} + +.btn-login:active { + transform: translateY(0); +} + +.btn-login:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +.error-banner { + background: rgba(255, 50, 50, 0.1); + border: 1px solid rgba(255, 50, 50, 0.2); + color: #ff5555; + padding: 0.75rem; + border-radius: 0.75rem; + font-size: 0.875rem; + margin-bottom: 1.5rem; + text-align: center; +} + +.login-footer { + margin-top: 2rem; + text-align: center; + font-size: 0.875rem; + color: #888; +} + +.login-footer a { + color: #39ff14; + text-decoration: none; + font-weight: 500; +} + +.login-footer a:hover { + text-decoration: underline; +} + +.spinner { + width: 20px; + height: 20px; + border: 3px solid rgba(0, 0, 0, 0.1); + border-top-color: #000; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.separator { + display: flex; + align-items: center; + text-align: center; + margin: 1.5rem 0; + color: #555; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.1em; +} + +.separator::before, +.separator::after { + content: ''; + flex: 1; + border-bottom: 1px solid #2a2a2a; +} + +.separator span { + padding: 0 1rem; +} + +.btn-google { + width: 100%; + padding: 0.875rem; + background: #1a1a1a; + color: #fff; + border: 1px solid #333; + border-radius: 0.75rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + justify-content: center; + align-items: center; + gap: 0.75rem; +} + +.btn-google img { + width: 20px; + height: 20px; +} + +.btn-google:hover { + background: #252525; + border-color: #444; +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor new file mode 100644 index 0000000..ded75f2 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor @@ -0,0 +1,79 @@ +@page "/account/profile" +@using Microsoft.AspNetCore.Authorization +@using NexusReader.UI.Shared.Services +@attribute [Authorize] +@inject IIdentityService IdentityService +@inject NavigationManager NavigationManager + +
+
+ @if (_profile == null) + { +
+
+

Fetching your Nexus profile...

+
+ } + else + { +
+
+ @(string.IsNullOrEmpty(_profile.Email) ? "?" : _profile.Email[0].ToString().ToUpper()) +
+

@_profile.Email

+
+ @_profile.CurrentPlan Plan +
+
+ +
+
+
+ AI Token Usage + @_profile.AITokensUsed / @_profile.AITokenLimit +
+
+
+
+ +
+
+ +
+ + +
+ } +
+
+ +@code { + private UserProfile? _profile; + + protected override async Task OnInitializedAsync() + { + _profile = await IdentityService.GetProfileAsync(); + } + + private int CalculateProgress() + { + if (_profile == null || _profile.AITokenLimit == 0) return 0; + var percent = (int)((double)_profile.AITokensUsed / _profile.AITokenLimit * 100); + return Math.Min(percent, 100); + } + + private void HandleUpgrade() + { + // Future: Redirect to Stripe billing portal + } + + private async Task HandleLogout() + { + await IdentityService.LogoutAsync(); + NavigationManager.NavigateTo("/account/login"); + } +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css new file mode 100644 index 0000000..d05abeb --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css @@ -0,0 +1,173 @@ +.profile-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: #0a0a0a; + padding: 2rem; +} + +.profile-card { + width: 100%; + max-width: 500px; + background: rgba(20, 20, 20, 0.8); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 2rem; + padding: 3rem; + box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.6); +} + +.profile-header { + text-align: center; + margin-bottom: 3rem; +} + +.user-avatar { + width: 80px; + height: 80px; + background: linear-gradient(135deg, #39ff14 0%, #1a8a0a 100%); + border-radius: 50%; + margin: 0 auto 1.5rem; + display: flex; + justify-content: center; + align-items: center; + font-size: 2rem; + font-weight: 700; + color: #000; + box-shadow: 0 0 20px rgba(57, 255, 20, 0.3); +} + +.profile-header h2 { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 0.75rem; +} + +.plan-badge { + display: inline-block; + padding: 0.4rem 1rem; + border-radius: 2rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.plan-badge.free { + background: rgba(255, 255, 255, 0.1); + color: #888; +} + +.plan-badge.pro { + background: rgba(57, 255, 20, 0.1); + color: #39ff14; + border: 1px solid rgba(57, 255, 20, 0.2); +} + +.profile-stats { + margin-bottom: 3rem; +} + +.stat-group { + background: rgba(255, 255, 255, 0.03); + padding: 1.5rem; + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.stat-header { + display: flex; + justify-content: space-between; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 1rem; + color: #bbb; +} + +.usage-count { + color: #fff; + font-weight: 700; +} + +.progress-bar { + width: 100%; + height: 8px; + background: #1a1a1a; + border-radius: 4px; + overflow: hidden; + margin-bottom: 0.75rem; +} + +.progress-fill { + height: 100%; + background: #39ff14; + box-shadow: 0 0 10px rgba(57, 255, 20, 0.5); + border-radius: 4px; + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); +} + +.stat-footer { + font-size: 0.75rem; + color: #666; + margin: 0; +} + +.profile-actions { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.btn-primary { + width: 100%; + padding: 1rem; + background: #39ff14; + color: #000; + border: none; + border-radius: 1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(57, 255, 20, 0.3); +} + +.btn-outline { + width: 100%; + padding: 1rem; + background: transparent; + color: #ff5555; + border: 1px solid rgba(255, 85, 85, 0.2); + border-radius: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-outline:hover { + background: rgba(255, 85, 85, 0.05); + border-color: rgba(255, 85, 85, 0.4); +} + +.loading-state { + text-align: center; + padding: 4rem 0; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(57, 255, 20, 0.1); + border-top-color: #39ff14; + border-radius: 50%; + margin: 0 auto 1.5rem; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Register.razor b/src/NexusReader.UI.Shared/Pages/Account/Register.razor new file mode 100644 index 0000000..1eec73d --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Account/Register.razor @@ -0,0 +1,112 @@ +@page "/account/register" +@using Microsoft.AspNetCore.Components.Forms +@using NexusReader.UI.Shared.Services +@inject IIdentityService IdentityService +@inject NavigationManager NavigationManager + + + +@code { + private RegisterModel _registerModel = new(); + private string? _errorMessage; + private bool _isSubmitting; + + private async Task HandleRegister() + { + if (_registerModel.Password != _registerModel.ConfirmPassword) + { + _errorMessage = "Passwords do not match."; + return; + } + + _isSubmitting = true; + _errorMessage = null; + + try + { + var success = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password); + if (success) + { + // Registration successful, redirect to login + NavigationManager.NavigateTo("/account/login?registered=true"); + } + else + { + _errorMessage = "Registration failed. Email might already be in use."; + } + } + catch (Exception) + { + _errorMessage = "An error occurred during registration. Please try again."; + } + finally + { + _isSubmitting = false; + } + } + + public class RegisterModel + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Email { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.MinLength(8)] + public string Password { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + public string ConfirmPassword { get; set; } = string.Empty; + } +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Register.razor.css b/src/NexusReader.UI.Shared/Pages/Account/Register.razor.css new file mode 100644 index 0000000..fef2a55 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Account/Register.razor.css @@ -0,0 +1,174 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: #0a0a0a; + background-image: radial-gradient(circle at 50% 50%, #1a1a1a 0%, #0a0a0a 100%); + font-family: 'Inter', sans-serif; + color: #e0e0e0; +} + +.login-card { + width: 100%; + max-width: 400px; + padding: 2.5rem; + background: rgba(20, 20, 20, 0.8); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 1.5rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); +} + +.login-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.login-header h1 { + font-size: 2.5rem; + font-weight: 800; + letter-spacing: -0.05em; + margin: 0; + color: #ffffff; +} + +.login-header h1 span { + color: #39ff14; /* Neon Green */ + text-shadow: 0 0 10px rgba(57, 255, 20, 0.5); +} + +.login-header p { + color: #888; + margin-top: 0.5rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: #bbb; +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + background: #151515; + border: 1px solid #2a2a2a; + border-radius: 0.75rem; + color: #fff; + font-size: 1rem; + transition: all 0.2s ease; +} + +.form-control:focus { + outline: none; + border-color: #39ff14; + box-shadow: 0 0 0 4px rgba(57, 255, 20, 0.1); +} + +.form-options { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; + margin-bottom: 2rem; +} + +.remember-me { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.remember-me input { + accent-color: #39ff14; +} + +.forgot-link { + color: #888; + text-decoration: none; + transition: color 0.2s; +} + +.forgot-link:hover { + color: #39ff14; +} + +.btn-login { + width: 100%; + padding: 0.875rem; + background: #39ff14; + color: #000; + border: none; + border-radius: 0.75rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + justify-content: center; + align-items: center; +} + +.btn-login:hover { + transform: translateY(-2px); + box-shadow: 0 0 20px rgba(57, 255, 20, 0.4); + background: #32e612; +} + +.btn-login:active { + transform: translateY(0); +} + +.btn-login:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +.error-banner { + background: rgba(255, 50, 50, 0.1); + border: 1px solid rgba(255, 50, 50, 0.2); + color: #ff5555; + padding: 0.75rem; + border-radius: 0.75rem; + font-size: 0.875rem; + margin-bottom: 1.5rem; + text-align: center; +} + +.login-footer { + margin-top: 2rem; + text-align: center; + font-size: 0.875rem; + color: #888; +} + +.login-footer a { + color: #39ff14; + text-decoration: none; + font-weight: 500; +} + +.login-footer a:hover { + text-decoration: underline; +} + +.spinner { + width: 20px; + height: 20px; + border: 3px solid rgba(0, 0, 0, 0.1); + border-top-color: #000; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/NexusReader.UI.Shared/Routes.razor b/src/NexusReader.UI.Shared/Routes.razor index 6c3ccf6..04302ec 100644 --- a/src/NexusReader.UI.Shared/Routes.razor +++ b/src/NexusReader.UI.Shared/Routes.razor @@ -2,7 +2,11 @@ - + + +

You are not authorized to access this resource. Please login.

+
+
diff --git a/src/NexusReader.UI.Shared/Services/IdentityService.cs b/src/NexusReader.UI.Shared/Services/IdentityService.cs new file mode 100644 index 0000000..8de1b79 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/IdentityService.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Json; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.UI.Shared.Services; + +public interface IIdentityService +{ + Task RegisterAsync(string email, string password); + Task LoginAsync(string email, string password); + Task LogoutAsync(); + Task GetProfileAsync(); +} + +public record UserProfile( + string Email, + int AITokenLimit, + int AITokensUsed, + string CurrentPlan, + Guid TenantId); + +public class IdentityService : IIdentityService +{ + private readonly HttpClient _httpClient; + private readonly INativeStorageService _storageService; + private readonly NexusAuthenticationStateProvider _authStateProvider; + private const string TokenKey = "nexus_auth_token"; + + public IdentityService( + HttpClient httpClient, + INativeStorageService storageService, + NexusAuthenticationStateProvider authStateProvider) + { + _httpClient = httpClient; + _storageService = storageService; + _authStateProvider = authStateProvider; + } + + public async Task RegisterAsync(string email, string password) + { + var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password }); + return response.IsSuccessStatusCode; + } + + public async Task LoginAsync(string email, string password) + { + var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password }); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(); + if (result != null && !string.IsNullOrEmpty(result.AccessToken)) + { + await _storageService.SaveSecureString(TokenKey, result.AccessToken); + _authStateProvider.NotifyUserAuthentication(result.AccessToken); + return true; + } + } + + return false; + } + + public async Task LogoutAsync() + { + _storageService.RemoveSecure(TokenKey); + _authStateProvider.NotifyUserLogout(); + } + + public async Task GetProfileAsync() + { + try + { + return await _httpClient.GetFromJsonAsync("identity/profile"); + } + catch + { + return null; + } + } + + private class LoginResponse + { + public string TokenType { get; set; } = string.Empty; + public string AccessToken { get; set; } = string.Empty; + public int ExpiresIn { get; set; } + public string RefreshToken { get; set; } = string.Empty; + } +} diff --git a/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs new file mode 100644 index 0000000..1997b27 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Components.Authorization; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.UI.Shared.Services; + +public class NexusAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly INativeStorageService _storageService; + private const string TokenKey = "nexus_auth_token"; + + public NexusAuthenticationStateProvider(INativeStorageService storageService) + { + _storageService = storageService; + } + + public override async Task GetAuthenticationStateAsync() + { + try + { + var result = await _storageService.GetSecureString(TokenKey); + var token = result.IsSuccess ? result.Value : null; + + if (string.IsNullOrWhiteSpace(token)) + { + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + + var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt"); + var user = new ClaimsPrincipal(identity); + + return new AuthenticationState(user); + } + catch (Exception) + { + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + } + + public void NotifyUserAuthentication(string token) + { + var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt"); + var user = new ClaimsPrincipal(identity); + var authState = Task.FromResult(new AuthenticationState(user)); + NotifyAuthenticationStateChanged(authState); + } + + public void NotifyUserLogout() + { + var guest = new ClaimsPrincipal(new ClaimsIdentity()); + var authState = Task.FromResult(new AuthenticationState(guest)); + NotifyAuthenticationStateChanged(authState); + } + + private IEnumerable ParseClaimsFromJwt(string jwt) + { + var claims = new List(); + var payload = jwt.Split('.')[1]; + + var jsonBytes = ParseBase64WithoutPadding(payload); + var keyValuePairs = JsonSerializer.Deserialize>(jsonBytes); + + if (keyValuePairs != null) + { + claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString() ?? string.Empty))); + } + + return claims; + } + + private byte[] ParseBase64WithoutPadding(string base64) + { + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + return Convert.FromBase64String(base64); + } +} diff --git a/src/NexusReader.UI.Shared/_Imports.razor b/src/NexusReader.UI.Shared/_Imports.razor index ebf5b2c..3b05aa7 100644 --- a/src/NexusReader.UI.Shared/_Imports.razor +++ b/src/NexusReader.UI.Shared/_Imports.razor @@ -3,6 +3,7 @@ @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Authorization @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop diff --git a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj index 73c76f8..b1e1fc6 100644 --- a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj +++ b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/NexusReader.Web.New/Controllers/StripeWebhookController.cs b/src/NexusReader.Web.New/Controllers/StripeWebhookController.cs new file mode 100644 index 0000000..353b307 --- /dev/null +++ b/src/NexusReader.Web.New/Controllers/StripeWebhookController.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using NexusReader.Domain.Entities; +using Stripe; + +namespace NexusReader.Web.New.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class StripeWebhookController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly IConfiguration _configuration; + private readonly string _webhookSecret; + + public StripeWebhookController(UserManager userManager, IConfiguration configuration) + { + _userManager = userManager; + _configuration = configuration; + _webhookSecret = _configuration["Stripe:WebhookSecret"] ?? ""; + } + + [HttpPost] + public async Task Index() + { + var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); + + try + { + var stripeEvent = EventUtility.ConstructEvent( + json, + Request.Headers["Stripe-Signature"], + _webhookSecret + ); + + switch (stripeEvent.Type) + { + case EventTypes.CheckoutSessionCompleted: + var session = stripeEvent.Data.Object as Stripe.Checkout.Session; + await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata); + break; + + case EventTypes.CustomerSubscriptionUpdated: + var subscription = stripeEvent.Data.Object as Stripe.Subscription; + // Subscription update might not have email directly, would need to fetch customer + // For now, assuming email is in metadata if we set it during checkout + await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata); + break; + + case EventTypes.CustomerSubscriptionDeleted: + var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription; + await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"]); + break; + } + + return Ok(); + } + catch (StripeException e) + { + return BadRequest(e.Message); + } + } + + private async Task HandleSubscriptionSuccess(string? email, Dictionary? metadata) + { + if (string.IsNullOrEmpty(email)) return; + + var user = await _userManager.FindByEmailAsync(email); + if (user != null) + { + var plan = metadata != null && metadata.ContainsKey("Plan") ? metadata["Plan"] : "Pro"; + + user.CurrentPlan = plan; + user.AITokenLimit = plan.ToLower() switch + { + "pro" => 50000, + "enterprise" => 500000, + _ => 10000 // default for unknown or free + }; + + await _userManager.UpdateAsync(user); + } + } + + private async Task HandleSubscriptionCancellation(string? email) + { + if (string.IsNullOrEmpty(email)) return; + + var user = await _userManager.FindByEmailAsync(email); + if (user != null) + { + user.CurrentPlan = "Free"; + user.AITokenLimit = 5000; // Free tier limit + await _userManager.UpdateAsync(user); + } + } +} diff --git a/src/NexusReader.Web.New/NexusReader.Web.csproj b/src/NexusReader.Web.New/NexusReader.Web.csproj index 4c2af81..90fb241 100644 --- a/src/NexusReader.Web.New/NexusReader.Web.csproj +++ b/src/NexusReader.Web.New/NexusReader.Web.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -9,8 +9,15 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - + diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index 79e9c3a..8208e9f 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -3,7 +3,17 @@ using NexusReader.Application; using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; using NexusReader.Web.Client.Services; +using NexusReader.Web.New.Services; using NexusReader.UI.Shared.Services; +using NexusReader.Domain.Entities; +using NexusReader.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; +using NexusReader.Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); @@ -12,6 +22,8 @@ builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); +builder.Services.AddControllers(); + // Enable detailed circuit errors for Server‑Side Blazor components builder.Services.AddServerSideBlazor() .AddCircuitOptions(options => @@ -20,6 +32,7 @@ builder.Services.AddServerSideBlazor() }); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -28,16 +41,77 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHttpClient("NexusAPI", client => +{ + client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000"); +}); +builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddCascadingAuthenticationState(); + builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +// Authorization Policies +builder.Services.AddScoped(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise")); + options.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); +}); + +// Authentication +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddGoogle(options => + { + options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id"; + options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "placeholder-secret"; + }); + +builder.Services.AddIdentityApiEndpoints() + .AddEntityFrameworkStores(); + +builder.Services.ConfigureApplicationCookie(options => +{ + options.Cookie.HttpOnly = true; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; +}); + +builder.Services.Configure(options => +{ + // Password settings + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequireUppercase = true; + options.Password.RequiredLength = 8; + options.Password.RequiredUniqueChars = 1; + + // Lockout settings + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; + + // User settings + options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; + options.User.RequireUniqueEmail = true; +}); + var app = builder.Build(); // Ensure Database is initialized using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Database.EnsureCreated(); + await dbContext.Database.MigrateAsync(); } // Configure the HTTP request pipeline. @@ -58,7 +132,10 @@ if (!app.Environment.IsDevelopment()) } app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); app.MapStaticAssets(); +app.MapControllers(); // API endpoint for WASM client to fetch EPUB content app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) => @@ -100,6 +177,67 @@ app.MapDelete("/api/knowledge", async (IKnowledgeService knowledgeService) => return Results.BadRequest(errorMsg); }); +app.MapGroup("/identity").MapIdentityApi(); + +app.MapGet("/identity/login/google", (string? returnUrl) => +{ + var properties = new AuthenticationProperties + { + RedirectUri = "/identity/callback/google", + Items = { { "returnUrl", returnUrl ?? "/" } } + }; + return Results.Challenge(properties, new[] { "Google" }); +}); + +app.MapGet("/identity/callback/google", async ( + HttpContext context, + SignInManager signInManager, + UserManager userManager) => +{ + var info = await signInManager.GetExternalLoginInfoAsync(); + if (info == null) return Results.Redirect("/account/login?error=ExternalLoginFailed"); + + var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); + if (result.Succeeded) + { + return Results.Redirect("/"); + } + + // New user provisioning + var email = info.Principal.FindFirstValue(ClaimTypes.Email); + if (email != null) + { + var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true }; + var createResult = await userManager.CreateAsync(user); + if (createResult.Succeeded) + { + await userManager.AddLoginAsync(user, info); + await signInManager.SignInAsync(user, isPersistent: false); + return Results.Redirect("/"); + } + } + + return Results.Redirect("/account/login?error=ProvisioningFailed"); +}); + +app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager userManager) => +{ + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Results.Unauthorized(); + + var nexusUser = await userManager.FindByIdAsync(userId); + if (nexusUser == null) return Results.NotFound(); + + return Results.Ok(new + { + nexusUser.Email, + nexusUser.AITokenLimit, + nexusUser.AITokensUsed, + nexusUser.CurrentPlan, + nexusUser.TenantId + }); +}).RequireAuthorization(); + app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() diff --git a/src/NexusReader.Web.New/Services/WebStorageService.cs b/src/NexusReader.Web.New/Services/WebStorageService.cs new file mode 100644 index 0000000..c9f20d1 --- /dev/null +++ b/src/NexusReader.Web.New/Services/WebStorageService.cs @@ -0,0 +1,91 @@ +using FluentResults; +using Microsoft.JSInterop; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Web.New.Services; + +public class WebStorageService : INativeStorageService +{ + private readonly IJSRuntime _jsRuntime; + + public WebStorageService(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public Result SaveString(string key, string value) + { + try + { + // Note: We can't use await in a non-async method, + // but for Blazor Server/WASM we usually want async. + // However, INativeStorageService has some non-async methods. + // We'll use InvokeVoidAsync and ignore the task if needed, or implement them properly. + _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result GetString(string key) + { + // This is problematic for synchronous Blazor Server calls. + // But in InteractiveAuto/WASM it should be fine if called from async context. + // For simplicity and since we mostly care about the async ones for auth: + return Result.Fail("Use GetStringAsync or similar if available, or call from async context."); + } + + public Result SaveBool(string key, bool value) => SaveString(key, value.ToString()); + + public Result GetBool(string key, bool defaultValue = false) + { + return Result.Ok(defaultValue); + } + + public Result Remove(string key) + { + try + { + _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task SaveSecureString(string key, string value) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task> GetSecureString(string key) + { + try + { + var value = await _jsRuntime.InvokeAsync("localStorage.getItem", key); + return Result.Ok(value); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result RemoveSecure(string key) + { + return Remove(key); + } +} diff --git a/src/NexusReader.Web.New/appsettings.json b/src/NexusReader.Web.New/appsettings.json index 0af7df8..1e66694 100644 --- a/src/NexusReader.Web.New/appsettings.json +++ b/src/NexusReader.Web.New/appsettings.json @@ -9,6 +9,12 @@ "ConnectionStrings": { "SqliteConnection": "Data Source=nexus.db" }, + "Authentication": { + "Google": { + "ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com", + "ClientSecret": "YOUR_CLIENT_SECRET" + } + }, "Ai": { "Google": { "ApiKey": "PLACEHOLDER", diff --git a/src/NexusReader.Web.New/nexus.db-shm b/src/NexusReader.Web.New/nexus.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..ea5e4ded85790ca710b4b7a68292b6584e84ac7f GIT binary patch literal 32768 zcmeI)KWYL&6bIn(Pc-Rlgax%vlQJjB0fL1^DhZ_W0v^E|SX+va(lU1u!AqpkH?js> zo4~H$58e(lEW7i*1H5AYl5*Nnjg)#%v5um4^X123G+jJTzE|(-&DYOt{Q35}{Vi^M zKR$KNV?2*nDUbh__;>0@4a&Oghq2H1$>&0V009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5Fk)Dfkt|$ zTTA*OK!5-N0t5&UAV7dX?FCvf!!S=T6ax1r(7nHwbxfcX$kSzo009C72oNAZfB*pk t1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oR{YKr?l!-N3)y{si?VC!PQR literal 0 HcmV?d00001 diff --git a/src/NexusReader.Web.New/nexus.db-wal b/src/NexusReader.Web.New/nexus.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..cc93d51ec767f59c53cf65710d678580953adffe GIT binary patch literal 37112 zcmeHQTWlj&8TMpjJMreS+1qTJu9%%v><-zqPPT25rD!qEWY@%NXC0>`AmmQ%X?C>s zOzIgoO(dwC)ow+L0HLZp!czs6C_+?%hw_NT3lB&<01^UK;uY~m(MpJO8_${XOq@1E z?B%yM$;`R@=b!KY&&B_o^X=^GiQU-LM`vQOV=?@>u=>*<6@UC*t@ZZW=fD5iKR%6; z1U~&+-}&Z0-yQtS`IQ@a!_lRet!6`Wq)VuFUtjEtl7z}0K|kZT4*QIuzy0nF`b*?- z_hFYuVo%*w;<)hWJ)D1P^xnwNQtHv2f!_|in)r8oDDkVl|MdS(nCSa;?1v!&+QPA< zn3)pV=M1x^zm{98WbDm??v$*$o~>&}!>-`VTrHS9mMy87vMS{Y3+kFAM^}`kVnLFF z)n(~|Oa;rzZE++iPE84SG)H@>uG6Bcw%#mTw{+7E-jA}D%b73bRadfC97#?|=p*B# zTuqkB>RMSU6w%LWK7Wzs<*g0FTy9#g8a2HcUZkKk^zfV)w0f(P)sdMk7FNooOs-It zBd^wSDR(_nT9=m8b@cE|yNi0F$8P6`lH%l~aJLvREw(*pS#moZWmuBCFS|0A3bQ$nq|ZJ~dX#yX$X@m! zQn=3J_8VF9o5K|sVh*NAlsS4h1g`-0gv0QMFAOKd8sJJ9>1QT{-)I}YLRKw-9*-LAf*Z-@73x4?p7Z`QSKzVml_xZ7vLT8Q*6S69D# z`Bg@J-fbT%*vr+ZNUr-GhN-U{-Cx&d%5b)(DMPzEgLG6sA22qZR=lBt-lw9BO|v`3 z#v?i!W7Vr_sWYfck?6aXdGs@@bYf)`OuWp^jpZBE>+>GM!?diMi9 zsbSMUorw2uPEWQh^)e?e8#VR+56$VRbf^P=O4*%>C&l@B;r8pqpeuSqGaaM4WLjHw zy|$rewdzehJT>7rM0lZ2@5)-Hqnpk(&Au5JCxd+~N)Trn*0;WFTjl~<+64r%-0N0N zuSeMmjf#y;=LIyG8I~DdKikwbN3Ug^Kz2%5N+iU}eAHwWu771ZJUw|wKavzDCWN=f z0;3r>YuWvNti2%)FS0y;_YE>ZTz!?g!OY~yS}Sif4D`gofskR!3fX8>3mGk0p|Wlw zCyGKo+iEtEBP`do2;A|A89qxE@K>F>v7^_b%cu>_sJj;PCRnd(L;;eFob+X*+1TUR zmNnbnvYIvCkG)n_^lGbVINK|Z*4XS;0*OcY=;^CbC6;emre0_@Uh=*>c+$Dd-insD ztVONrSWVT$UwxvjmA9(5td^sioi8+Ai=Di^MP#aI?6(&Y@Omw4wagIzNh#0vC&b)D zRHxyblV9xH@%EZPy2tJo*FEs?$hrr)>EzFo#l&9{lPDWLfB+x>2mk_~4uN*&Oj4Yl7Ivq|#8j!Mi`R{frskjprHv=f?v!!= zG}T=I%~r$SYA(123ohuw>hlv96IC`Ey3H?0Sio{PrL>wSwj` zcl91#;&Jw9tA*T+RX%nbUYr+5!?@w)5Fj$wwybQPPKxIzg!Tkc*j)zZaD>L4fBf5xlXp@@f1{Drf;te97*{G)R@%ae&_F$IiN#>>C9eQYKD~zqzKTH= zXeQQy3dTAdkJN#MHG(?O2mk_r03ZMe00Mvj zAOHwFoCx^h0#~2>@E>nZ{=md>0b%sdG4u~UfB+x>2mk_r03ZMe00MvjAOHve0)PM@ z@W3JPq;MowoJr3-HDOr_<=MeEb5!+=qYt zL!YwKgJ0k*2gh-A4g3PYFF?08(qmx!$ywhqRd%GG+pY`z0+PEM_Y<7}XPZm8J>TAz zTC_6>XQNYiy$*d7DTjK@w1-3R3gBJ7hjHlqp!*?#Ux1N*-2eOE4!qeB_ysz2GrobF z?WaH7fgAV*fM0;J4gkLZIf&9ljud`U3it&W`vC9@0KdR%U7d!Xv^Yi~;1|G53j5<1 z_~l36JNMP+mR=l=S7KxR&-dUL=zl(bl8X?)B_IF@Jn{&%S077?v$Mi(F67YsHN!@H zHQWAU?JV&LcI=i>bB2~d$8720`OZ1~E)Jr-kI=KWd@NvWRe?@?)mqgE!l;yzdpse2 zeKv~RCRE$XbZ9aTs}5j40e%6Vf(-ZtJVspL7YGm&@CWX301@~FFrxzS3lP3(>=5*o zLuY=VR1z*f8}K)O3jXGU#?ygc0Qd#Gp?V1T1s*7VfhX_+)#Vo${P>O8@!z~Z3;Y5Y z75D%GfB+x>2mk_r03ZMe00MvjAOHve0uMa`_~q4EH!d)F?A!nP;1|DNPvOU=|Fz*m z4*)zMAOHve0)PM@00;mAfB+x>2mk_r03h%PBY=4aJ8^;aceeiaM&<0^-TeZ2mk_r03ZMe00Mx(1B}4_cnALrhhgfw literal 0 HcmV?d00001