Initial commit: NexusArchitect Professional Workstation Overhaul
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Definition of Done (DoD)
|
||||||
|
|
||||||
|
1. **Architecture Compliance:** Feature follows CQRS flow. Logic is in Handlers. Result is wrapped in `Result<T>` from FluentResult.
|
||||||
|
2. **Modularization:** Code is in `/src`, tests in `/tests`. Module-specific logic is isolated.
|
||||||
|
3. **UI/UX Integrity:** - "Vertical Flow Check" passed (Assistant is part of the document stream, not an absolute pop-up).
|
||||||
|
- No "Layout Shift" during AI content streaming.
|
||||||
|
- Safe-area-insets respected for iOS/Android notches.
|
||||||
|
4. **Code Quality:** C# 14 syntax used (Primary Constructors, etc.). Scoped CSS (.razor.css) implemented.
|
||||||
|
5. **D3.js Performance:** JS Modules correctly disposed using `IAsyncDisposable`.
|
||||||
|
6. **Persistence:** State survives manual page refresh (Local/Session Storage integration).
|
||||||
|
7. **Mapping:** All entity-to-DTO conversions must use Mapster.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Agent Personas
|
||||||
|
|
||||||
|
## NexusArchitect
|
||||||
|
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
|
||||||
|
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
|
||||||
|
- **Architecture Role:** Lead Clean Architecture Specialist.
|
||||||
|
- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator]
|
||||||
|
- **Technical Constraints:**
|
||||||
|
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level.
|
||||||
|
- **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components.
|
||||||
|
- **Error Handling:** All handlers must return `Result<T>` via `FluentResult`.
|
||||||
|
- **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper.
|
||||||
|
- **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance.
|
||||||
|
- **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code.
|
||||||
|
- **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: blazor-hybrid-bridge
|
||||||
|
description: Standards for cross-platform compatibility (Web & MAUI Hybrid)
|
||||||
|
---
|
||||||
|
# Cross-Platform Integration
|
||||||
|
|
||||||
|
- **Abstraction Layer:**
|
||||||
|
- Define all platform-specific logic in `NexusReader.Application/Abstractions/Services`.
|
||||||
|
- Use `IPlatformService` for device hardware (Haptics, Orientation).
|
||||||
|
- Use `INativeStorageService` for persistent key-value storage.
|
||||||
|
|
||||||
|
- **Implementation Strategy:**
|
||||||
|
- **MAUI**: Implement in `NexusReader.Infrastructure.Mobile` using `Microsoft.Maui.*` APIs.
|
||||||
|
- **Web**: Implement in `NexusReader.Web.Client` using `IJSRuntime` to access Browser APIs (e.g., `navigator.vibrate`, `localStorage`).
|
||||||
|
|
||||||
|
- **UI Compatibility:**
|
||||||
|
- **Notch Support**: Use `env(safe-area-inset-top)` and other safe-area variables in CSS to prevent content from being hidden by hardware notches or home indicators.
|
||||||
|
- **Touch Optimization**:
|
||||||
|
- Use `user-select: none` for non-text interactive elements.
|
||||||
|
- Ensure touch targets are at least `44x44px`.
|
||||||
|
- Handle `active` states for immediate feedback on mobile.
|
||||||
|
|
||||||
|
- **Platform Detection:**
|
||||||
|
- Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop).
|
||||||
|
- Adapt UI layout dynamically based on the context (e.g., sidebars on Tablet/Desktop, bottom navigation on Phone).
|
||||||
|
|
||||||
|
- **Dependency Injection:**
|
||||||
|
- Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web.
|
||||||
|
- Components in `NexusReader.UI.Shared` must only depend on the interfaces.
|
||||||
|
|
||||||
|
- **JS Interop Standards:**
|
||||||
|
- Keep JS Interop calls isolated within service implementations.
|
||||||
|
- Use `IJSObjectReference` for module-based scripts to avoid global namespace pollution.
|
||||||
|
- Always provide a fallback or graceful failure for browsers that don't support specific APIs.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: nexus-clean-architecture
|
||||||
|
description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hybrid
|
||||||
|
---
|
||||||
|
# Clean Architecture Standards
|
||||||
|
|
||||||
|
- **Project Structure (Layered):**
|
||||||
|
- `NexusReader.Domain`: Enterprise business rules (Entities, Value Objects, Domain Events).
|
||||||
|
- `NexusReader.Application`: Application business rules (Commands, Queries, DTOs, Mappings, Interfaces).
|
||||||
|
- `NexusReader.Infrastructure`: Data access, external services, and platform-specific implementations.
|
||||||
|
- `NexusReader.UI.Shared`: UI logic and Blazor components.
|
||||||
|
- `NexusReader.Maui` / `NexusReader.Web`: Platform host projects.
|
||||||
|
|
||||||
|
- **CQRS & Messaging:**
|
||||||
|
- **MediatR**: Use standard `MediatR` for decoupling.
|
||||||
|
- **Queries**: Read-only operations, return `Task<Result<T>>`.
|
||||||
|
- **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`.
|
||||||
|
- **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`).
|
||||||
|
|
||||||
|
- **Functional Error Handling:**
|
||||||
|
- Mandatory use of `FluentResults`.
|
||||||
|
- No exceptions for business logic flow. Handlers must return `Result.Ok()` or `Result.Fail()`.
|
||||||
|
|
||||||
|
- **UI Architecture (Atomic Design):**
|
||||||
|
- Components in `NexusReader.UI.Shared` must follow Atomic Design:
|
||||||
|
- `Atoms`: Smallest functional units (Buttons, Inputs).
|
||||||
|
- `Molecules`: Groups of atoms (Search Bar, Form Field).
|
||||||
|
- `Organisms`: Complex UI sections (Navigation, Header).
|
||||||
|
- `Pages`: Composed organisms forming a full view.
|
||||||
|
|
||||||
|
- **Object Mapping:**
|
||||||
|
- Use `Mapster` for all DTO/Entity mappings.
|
||||||
|
- Centralize configuration in `NexusReader.Application/Mappings/MappingConfig.cs`.
|
||||||
|
- No `AutoMapper` allowed.
|
||||||
|
|
||||||
|
- **Cross-Platform Strategy:**
|
||||||
|
- Maximize code sharing in `NexusReader.UI.Shared`.
|
||||||
|
- Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
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`.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: nexus-ui-engine
|
||||||
|
description: Design System & Component rules for Blazor
|
||||||
|
---
|
||||||
|
# UI Standards
|
||||||
|
|
||||||
|
- **Atomic Design:**
|
||||||
|
- Build reusable components in `NexusReader.UI.Shared/Components`.
|
||||||
|
- `Atoms`: Base elements (`NexusButton`, `NexusIcon`, `NexusTypography`).
|
||||||
|
- `Molecules`: Small groups (`AiAssistantBubble`, `KnowledgeCheck`).
|
||||||
|
- `Organisms`: High-level sections (`KnowledgeGraph`, `ReaderCanvas`).
|
||||||
|
|
||||||
|
- **Styling & Isolation:**
|
||||||
|
- Mandatory use of scoped CSS (`.razor.css`).
|
||||||
|
- No global CSS except for design tokens in `app.css`.
|
||||||
|
- Use `::deep` only when absolutely necessary to style child components.
|
||||||
|
|
||||||
|
- **Design System (Nexus Neon):**
|
||||||
|
- **Color Palette:**
|
||||||
|
- Primary Accent: `--nexus-neon` (`#00ff99`) - Used for borders, highlights, and icons.
|
||||||
|
- Dark Mode: `--nexus-bg` (`#0a0a0a`), `--nexus-card` (`#141414`).
|
||||||
|
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
|
||||||
|
- **Typography:**
|
||||||
|
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
|
||||||
|
- Reading Content: `Merriweather` (Serif) for books and articles to ensure high readability.
|
||||||
|
- **Effects:**
|
||||||
|
- Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`).
|
||||||
|
- Glassmorphism for overlays and modals.
|
||||||
|
|
||||||
|
- **Adaptive Layouts:**
|
||||||
|
- Support `.platform-mobile` and `.platform-desktop` context classes.
|
||||||
|
- Handle safe-area insets (`--safe-area-inset-*`) for mobile devices.
|
||||||
|
|
||||||
|
- **Accessibility (A11y):**
|
||||||
|
- Touch Targets: Min `44x44px` on mobile (enforced via CSS variables).
|
||||||
|
- Contrast: Minimum ratio of 4.5:1.
|
||||||
|
- Semantic HTML: Use appropriate tags (`<button>`, `<article>`, `<nav>`).
|
||||||
|
|
||||||
|
- **Interactive Flow:**
|
||||||
|
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
|
||||||
|
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: semantic-kernel-orchestrator
|
||||||
|
description: Integrating AI logic with .NET Semantic Kernel
|
||||||
|
---
|
||||||
|
# AI Implementation Rules
|
||||||
|
|
||||||
|
- **Kernel Setup:** Use Microsoft Semantic Kernel with .NET 10.
|
||||||
|
- **Function Calling:** Define C# Plugins for "Graph Update" and "Quiz Generation".
|
||||||
|
- **Streaming:** Implement `IAsyncEnumerable<string>` for real-time assistant responses in the UI.
|
||||||
|
- **Context:** Ensure chapter metadata is passed as Semantic Memory.
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# .NET
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# Visual Studio
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
.gemini/
|
||||||
|
*.log
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# 📑 Project Backlog: Nexus AI E-Reader (Mockup Implementation)
|
||||||
|
**Architecture Framework:** .NET 10 | Blazor Component Model | CQRS with MediatR | FluentResult | Mapster
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 PHASE 1: Infrastructure & Design Tokens
|
||||||
|
*Goal: Prepare the clean architecture foundation and the visual DNA.*
|
||||||
|
|
||||||
|
### [TASK-01] Solution & Directory Structure Setup
|
||||||
|
- **Action:** Create the folder structure: `/src` for projects, `/tests` for unit/integration tests.
|
||||||
|
- **Details:** Initialize `NexusReader.Web` (Blazor), `NexusReader.Application`, `NexusReader.Domain`, and `NexusReader.Infrastructure`.
|
||||||
|
- **DoD:** Solution compiles with strict folder separation.
|
||||||
|
|
||||||
|
### [TASK-02] Core Library Integration
|
||||||
|
- **Action:** Install and configure LuckyPennySoftware.MediatR, Mapster, and FluentResult.
|
||||||
|
- **Details:** - Setup `MappingConfig` for Mapster in the Application layer.
|
||||||
|
- Implement a `BaseHandler` that returns `Result<T>`.
|
||||||
|
- **DoD:** A sample Query returns a `Success` Result via MediatR.
|
||||||
|
|
||||||
|
### [TASK-03] Nexus Neon Design System
|
||||||
|
- **Action:** Implement global CSS variables in `app.css` and base Atoms.
|
||||||
|
- **Details:** - Variables: `--nexus-neon: #00ff99`, `--nexus-bg: #121212`, `--nexus-card: #1e1e1e`.
|
||||||
|
- Components: `NexusButton.razor`, `NexusTypography.razor` (handling Serif for ebook, Sans for UI).
|
||||||
|
- **DoD:** Variables are accessible via all scoped CSS files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 PHASE 2: Seamless Reader & AI Assistant (Left Side / Inline)
|
||||||
|
*Goal: Implement the ebook reading logic and the "Vertical Flow" AI injection.*
|
||||||
|
|
||||||
|
### [TASK-04] ReaderCanvas & Dynamic Content Injection
|
||||||
|
- **Action:** Create `ReaderCanvas.razor` to render ebook text.
|
||||||
|
- **Details:** - Logic to split text into blocks.
|
||||||
|
- Implementation of an "Injection Point" system where `AiAssistantBubble.razor` can be rendered inline between paragraphs.
|
||||||
|
- **Mockup Match:** Text must use the high-contrast Serif font from the mockup.
|
||||||
|
|
||||||
|
### [TASK-05] AiAssistantBubble Component
|
||||||
|
- **Action:** Implement the AI chat bubble with a robot avatar.
|
||||||
|
- **Details:** - Scoped CSS for the glowing border and dark glassmorphism background.
|
||||||
|
- Parameters for `DialogueText` and `ActionButtons` ("Pokaż więcej", "Rozwiąż quiz").
|
||||||
|
- Integration with Semantic Kernel for streaming text.
|
||||||
|
- **DoD:** Bubble appears smoothly in the text flow without absolute positioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 PHASE 3: Knowledge Graph & Brain (Right Side / Flow)
|
||||||
|
*Goal: Implement the D3.js graph and the "You are here" logic.*
|
||||||
|
|
||||||
|
### [TASK-06] D3.js Knowledge Graph Bridge
|
||||||
|
- **Action:** Implement `KnowledgeGraph.razor` with JS Interop.
|
||||||
|
- **Details:** - ES6 Module `knowledgeGraph.js` using D3.js v7.
|
||||||
|
- SVG ViewBox scaling for portrait orientation.
|
||||||
|
- Implementation of the "TU JESTEŚ" (You Are Here) pulsing label on the active node.
|
||||||
|
- **DoD:** Clicking a node in JS triggers a C# EventCallback via `DotNetObjectReference`.
|
||||||
|
|
||||||
|
### [TASK-07] Semantic Mapping Service
|
||||||
|
- **Action:** Create the `GetKnowledgeGraphQuery` (CQRS).
|
||||||
|
- **Details:** - Service uses Semantic Kernel to extract nodes from the current chapter.
|
||||||
|
- Mapster maps the AI raw response to the `GraphViewModel`.
|
||||||
|
- **DoD:** Graph updates dynamically when the reader moves to a new chapter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 PHASE 4: Verification & Mobile Polish
|
||||||
|
*Goal: Implement the quiz module and cross-platform readiness.*
|
||||||
|
|
||||||
|
### [TASK-08] KnowledgeCheck (Quiz) Module
|
||||||
|
- **Action:** Implement the `SubmitAnswerCommand` using MediatR.
|
||||||
|
- **Details:** - UI: `KnowledgeCheck.razor` with radio buttons and a "Wyślij" (Submit) button.
|
||||||
|
- Logic: Handler returns `Result` (Success/Failure) via FluentResult.
|
||||||
|
- Mapster: Map `QuizDto` to `QuizViewModel`.
|
||||||
|
- **Mockup Match:** Neon highlight on the selected/correct answer.
|
||||||
|
|
||||||
|
### [TASK-09] Persistence & Cross-Platform (Hybrid)
|
||||||
|
- **Action:** Implement `IPlatformService` for Android/iOS support.
|
||||||
|
- **Details:** - Safe-area-insets implementation for notches.
|
||||||
|
- `BrowserStorage` implementation of `AppState` to save progress.
|
||||||
|
- Haptic feedback abstraction (trigger vibration on correct answer).
|
||||||
|
- **DoD:** App maintains graph state after a manual refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Instructions for NexusArchitect
|
||||||
|
1. **Vertical Flow Priority:** Ensure that the AI assistant and the Graph never overlay text. Use `Flexbox` or `Grid` for a single, continuous scrollable column in portrait mode.
|
||||||
|
2. **Result Pattern:** Every single Mediator Handler **must** return `FluentResults.Result`.
|
||||||
|
3. **Mapster:** Perform all DTO-to-UI mappings in the Query/Command Handlers, not in the Razor components.
|
||||||
|
4. **Isolated Styles:** All specific CSS for the Neon effect must be in `.razor.css` files.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<AndroidSdkDirectory>/home/debian/android-sdk</AndroidSdkDirectory>
|
||||||
|
<JavaSdkDirectory>/home/debian/java/jdk-17.0.10+7</JavaSdkDirectory>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/NexusReader.Application/NexusReader.Application.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Domain/NexusReader.Domain.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" />
|
||||||
|
<Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/src/NexusReader.Web.New/">
|
||||||
|
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Definition of Done (DoD)
|
||||||
|
|
||||||
|
1. **Architecture Compliance:** Feature follows CQRS flow. Logic is in Handlers. Result is wrapped in `Result<T>` from FluentResult.
|
||||||
|
2. **Modularization:** Code is in `/src`, tests in `/tests`. Module-specific logic is isolated.
|
||||||
|
3. **UI/UX Integrity:** - "Vertical Flow Check" passed (Assistant is part of the document stream, not an absolute pop-up).
|
||||||
|
- No "Layout Shift" during AI content streaming.
|
||||||
|
- Safe-area-insets respected for iOS/Android notches.
|
||||||
|
4. **Code Quality:** C# 14 syntax used (Primary Constructors, etc.). Scoped CSS (.razor.css) implemented.
|
||||||
|
5. **D3.js Performance:** JS Modules correctly disposed using `IAsyncDisposable`.
|
||||||
|
6. **Persistence:** State survives manual page refresh (Local/Session Storage integration).
|
||||||
|
7. **Mapping:** All entity-to-DTO conversions must use Mapster.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Agent Personas
|
||||||
|
|
||||||
|
## NexusArchitect
|
||||||
|
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
|
||||||
|
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
|
||||||
|
- **Architecture Role:** Lead Clean Architecture Specialist.
|
||||||
|
- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator]
|
||||||
|
- **Technical Constraints:**
|
||||||
|
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level.
|
||||||
|
- **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components.
|
||||||
|
- **Error Handling:** All handlers must return `Result<T>` via `FluentResult`.
|
||||||
|
- **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper.
|
||||||
|
- **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance.
|
||||||
|
- **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code.
|
||||||
|
- **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css).
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
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`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: semantic-kernel-orchestrator
|
||||||
|
description: Integrating AI logic with .NET Semantic Kernel
|
||||||
|
---
|
||||||
|
# AI Implementation Rules
|
||||||
|
|
||||||
|
- **Kernel Setup:** Use Microsoft Semantic Kernel with .NET 10.
|
||||||
|
- **Function Calling:** Define C# Plugins for "Graph Update" and "Quiz Generation".
|
||||||
|
- **Streaming:** Implement `IAsyncEnumerable<string>` for real-time assistant responses in the UI.
|
||||||
|
- **Context:** Ensure chapter metadata is passed as Semantic Memory.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 📑 Project Backlog: Nexus AI E-Reader (Mockup Implementation)
|
||||||
|
**Architecture Framework:** .NET 10 | Blazor Component Model | CQRS with MediatR | FluentResult | Mapster
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 PHASE 1: Infrastructure & Design Tokens
|
||||||
|
*Goal: Prepare the clean architecture foundation and the visual DNA.*
|
||||||
|
|
||||||
|
### [TASK-01] Solution & Directory Structure Setup
|
||||||
|
- **Action:** Create the folder structure: `/src` for projects, `/tests` for unit/integration tests.
|
||||||
|
- **Details:** Initialize `NexusReader.Web` (Blazor), `NexusReader.Application`, `NexusReader.Domain`, and `NexusReader.Infrastructure`.
|
||||||
|
- **DoD:** Solution compiles with strict folder separation.
|
||||||
|
|
||||||
|
### [TASK-02] Core Library Integration
|
||||||
|
- **Action:** Install and configure LuckyPennySoftware.MediatR, Mapster, and FluentResult.
|
||||||
|
- **Details:** - Setup `MappingConfig` for Mapster in the Application layer.
|
||||||
|
- Implement a `BaseHandler` that returns `Result<T>`.
|
||||||
|
- **DoD:** A sample Query returns a `Success` Result via MediatR.
|
||||||
|
|
||||||
|
### [TASK-03] Nexus Neon Design System
|
||||||
|
- **Action:** Implement global CSS variables in `app.css` and base Atoms.
|
||||||
|
- **Details:** - Variables: `--nexus-neon: #00ff99`, `--nexus-bg: #121212`, `--nexus-card: #1e1e1e`.
|
||||||
|
- Components: `NexusButton.razor`, `NexusTypography.razor` (handling Serif for ebook, Sans for UI).
|
||||||
|
- **DoD:** Variables are accessible via all scoped CSS files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 PHASE 2: Seamless Reader & AI Assistant (Left Side / Inline)
|
||||||
|
*Goal: Implement the ebook reading logic and the "Vertical Flow" AI injection.*
|
||||||
|
|
||||||
|
### [TASK-04] ReaderCanvas & Dynamic Content Injection
|
||||||
|
- **Action:** Create `ReaderCanvas.razor` to render ebook text.
|
||||||
|
- **Details:** - Logic to split text into blocks.
|
||||||
|
- Implementation of an "Injection Point" system where `AiAssistantBubble.razor` can be rendered inline between paragraphs.
|
||||||
|
- **Mockup Match:** Text must use the high-contrast Serif font from the mockup.
|
||||||
|
|
||||||
|
### [TASK-05] AiAssistantBubble Component
|
||||||
|
- **Action:** Implement the AI chat bubble with a robot avatar.
|
||||||
|
- **Details:** - Scoped CSS for the glowing border and dark glassmorphism background.
|
||||||
|
- Parameters for `DialogueText` and `ActionButtons` ("Pokaż więcej", "Rozwiąż quiz").
|
||||||
|
- Integration with Semantic Kernel for streaming text.
|
||||||
|
- **DoD:** Bubble appears smoothly in the text flow without absolute positioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 PHASE 3: Knowledge Graph & Brain (Right Side / Flow)
|
||||||
|
*Goal: Implement the D3.js graph and the "You are here" logic.*
|
||||||
|
|
||||||
|
### [TASK-06] D3.js Knowledge Graph Bridge
|
||||||
|
- **Action:** Implement `KnowledgeGraph.razor` with JS Interop.
|
||||||
|
- **Details:** - ES6 Module `knowledgeGraph.js` using D3.js v7.
|
||||||
|
- SVG ViewBox scaling for portrait orientation.
|
||||||
|
- Implementation of the "TU JESTEŚ" (You Are Here) pulsing label on the active node.
|
||||||
|
- **DoD:** Clicking a node in JS triggers a C# EventCallback via `DotNetObjectReference`.
|
||||||
|
|
||||||
|
### [TASK-07] Semantic Mapping Service
|
||||||
|
- **Action:** Create the `GetKnowledgeGraphQuery` (CQRS).
|
||||||
|
- **Details:** - Service uses Semantic Kernel to extract nodes from the current chapter.
|
||||||
|
- Mapster maps the AI raw response to the `GraphViewModel`.
|
||||||
|
- **DoD:** Graph updates dynamically when the reader moves to a new chapter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 PHASE 4: Verification & Mobile Polish
|
||||||
|
*Goal: Implement the quiz module and cross-platform readiness.*
|
||||||
|
|
||||||
|
### [TASK-08] KnowledgeCheck (Quiz) Module
|
||||||
|
- **Action:** Implement the `SubmitAnswerCommand` using MediatR.
|
||||||
|
- **Details:** - UI: `KnowledgeCheck.razor` with radio buttons and a "Wyślij" (Submit) button.
|
||||||
|
- Logic: Handler returns `Result` (Success/Failure) via FluentResult.
|
||||||
|
- Mapster: Map `QuizDto` to `QuizViewModel`.
|
||||||
|
- **Mockup Match:** Neon highlight on the selected/correct answer.
|
||||||
|
|
||||||
|
### [TASK-09] Persistence & Cross-Platform (Hybrid)
|
||||||
|
- **Action:** Implement `IPlatformService` for Android/iOS support.
|
||||||
|
- **Details:** - Safe-area-insets implementation for notches.
|
||||||
|
- `BrowserStorage` implementation of `AppState` to save progress.
|
||||||
|
- Haptic feedback abstraction (trigger vibration on correct answer).
|
||||||
|
- **DoD:** App maintains graph state after a manual refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Instructions for NexusArchitect
|
||||||
|
1. **Vertical Flow Priority:** Ensure that the AI assistant and the Graph never overlay text. Use `Flexbox` or `Grid` for a single, continuous scrollable column in portrait mode.
|
||||||
|
2. **Result Pattern:** Every single Mediator Handler **must** return `FluentResults.Result`.
|
||||||
|
3. **Mapster:** Perform all DTO-to-UI mappings in the Query/Command Handlers, not in the Razor components.
|
||||||
|
4. **Isolated Styles:** All specific CSS for the Neon effect must be in `.razor.css` files.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/NexusReader.Application/NexusReader.Application.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Domain/NexusReader.Domain.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" />
|
||||||
|
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/src/NexusReader.Web.New/">
|
||||||
|
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface ICommand : IRequest<Result>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommand<TResponse> : IRequest<Result<TResponse>>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
|
||||||
|
where TCommand : ICommand
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
|
||||||
|
where TCommand : ICommand<TResponse>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
|
||||||
|
where TQuery : IQuery<TResponse>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Queries.Quiz;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
public interface IAiGenerateQuizService
|
||||||
|
{
|
||||||
|
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
public interface IPlatformService
|
||||||
|
{
|
||||||
|
Task VibrateAsync(int milliseconds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.Quiz;
|
||||||
|
|
||||||
|
public record SubmitAnswerCommand(int SelectedIndex, int CorrectIndex) : ICommand;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.Quiz;
|
||||||
|
|
||||||
|
internal sealed class SubmitAnswerCommandHandler : ICommandHandler<SubmitAnswerCommand>
|
||||||
|
{
|
||||||
|
private readonly IPlatformService _platformService;
|
||||||
|
|
||||||
|
public SubmitAnswerCommandHandler(IPlatformService platformService)
|
||||||
|
{
|
||||||
|
_platformService = platformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(SubmitAnswerCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.SelectedIndex == request.CorrectIndex)
|
||||||
|
{
|
||||||
|
await _platformService.VibrateAsync(50);
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Fail("Incorrect answer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Mapster;
|
||||||
|
using MapsterMapper;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Mappings;
|
||||||
|
|
||||||
|
public static class MappingConfig
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddMapsterConfiguration(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
var config = TypeAdapterConfig.GlobalSettings;
|
||||||
|
|
||||||
|
// Scan assembly and register mappings
|
||||||
|
config.Scan(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
|
services.AddSingleton(config);
|
||||||
|
services.AddScoped<IMapper, ServiceMapper>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentResults" Version="4.0.0" />
|
||||||
|
<PackageReference Include="Mapster" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||||
|
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
|
public record GetKnowledgeGraphQuery : IQuery<GraphDataDto>;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
|
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
|
||||||
|
{
|
||||||
|
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var nodes = new List<GraphNodeDto>
|
||||||
|
{
|
||||||
|
new("renesans-intro", "Renesans", "Concept"),
|
||||||
|
new("florencja", "Florencja", "Location"),
|
||||||
|
new("medyceusze", "Medyceusze", "Entity"),
|
||||||
|
new("da-vinci-ai", "Leonardo da Vinci", "Person"),
|
||||||
|
new("humanizm", "Humanizm", "Concept")
|
||||||
|
};
|
||||||
|
|
||||||
|
var links = new List<GraphLinkDto>
|
||||||
|
{
|
||||||
|
new("renesans-intro", "florencja", 1),
|
||||||
|
new("florencja", "medyceusze", 2),
|
||||||
|
new("medyceusze", "da-vinci-ai", 3),
|
||||||
|
new("renesans-intro", "humanizm", 1),
|
||||||
|
new("da-vinci-ai", "humanizm", 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(Result.Ok(new GraphDataDto(nodes, links)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
|
public record GraphNodeDto(string Id, string Label, string Group);
|
||||||
|
public record GraphLinkDto(string Source, string Target, int Value);
|
||||||
|
public record GraphDataDto(List<GraphNodeDto> Nodes, List<GraphLinkDto> Links);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Quiz;
|
||||||
|
|
||||||
|
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Quiz;
|
||||||
|
|
||||||
|
internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler<GetQuizQuestionsQuery, QuizDto>
|
||||||
|
{
|
||||||
|
private readonly IAiGenerateQuizService _aiService;
|
||||||
|
|
||||||
|
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
|
||||||
|
{
|
||||||
|
_aiService = aiService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace NexusReader.Application.Queries.Quiz;
|
||||||
|
|
||||||
|
public record QuizQuestionDto(string Question, List<string> Options, int CorrectIndex);
|
||||||
|
public record QuizDto(List<QuizQuestionDto> Questions);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Reader;
|
||||||
|
|
||||||
|
public record GetReaderPageQuery : IQuery<ReaderPageViewModel>;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Reader;
|
||||||
|
|
||||||
|
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
|
||||||
|
{
|
||||||
|
public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var blocks = new List<ContentBlock>
|
||||||
|
{
|
||||||
|
new TextSegmentBlock("renesans-intro", "Renesans, nazywany również odrodzeniem, to epoka w historii kultury europejskiej, która zapoczątkowała odejście od średniowiecznego teocentryzmu na rzecz humanizmu. Narodził się we Włoszech, a dokładnie we Florencji, w XV wieku, skąd promieniował na całą Europę."),
|
||||||
|
new TextSegmentBlock("medyceusze", "Głównym mecenasem sztuki i nauki we Florencji był potężny ród Medyceuszy. To dzięki ich wsparciu miasto stało się kolebką nowożytnej myśli, gromadząc wokół siebie najwybitniejsze umysły tamtych czasów."),
|
||||||
|
new AiActionTriggerBlock("da-vinci-ai", "Leonardo da Vinci był jednym z najważniejszych twórców tego okresu. Czy chciałbyś dowiedzieć się więcej o jego najważniejszych wynalazkach, czy wolisz sprawdzić swoją dotychczasową wiedzę?", new List<string> { "Pokaż więcej", "Rozwiąż quiz" }),
|
||||||
|
new TextSegmentBlock("leonardo-detail", "Człowiek renesansu, uosabiany właśnie przez Leonarda, był wszechstronnie wykształcony. Interesował się sztuką, inżynierią, anatomią i filozofią, stawiając jednostkę w centrum wszechświata.")
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(Result.Ok(new ReaderPageViewModel(blocks)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace NexusReader.Application.Queries.Reader;
|
||||||
|
|
||||||
|
public abstract record ContentBlock(string Id);
|
||||||
|
public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id);
|
||||||
|
public record AiActionTriggerBlock(string Id, string Dialogue, List<string> ActionOptions) : ContentBlock(Id);
|
||||||
|
|
||||||
|
public record ReaderPageViewModel(List<ContentBlock> Blocks);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.System;
|
||||||
|
|
||||||
|
public record GetInitializationStatusQuery : IQuery<string>;
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.System;
|
||||||
|
|
||||||
|
internal sealed class GetInitializationStatusQueryHandler : IQueryHandler<GetInitializationStatusQuery, string>
|
||||||
|
{
|
||||||
|
public Task<Result<string>> Handle(GetInitializationStatusQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Ok("Nexus E-Reader Application is fully initialized and operational."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Application.Queries.Quiz;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class FakeAiGenerateQuizService : IAiGenerateQuizService
|
||||||
|
{
|
||||||
|
public async Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 2000ms delay to highlight Skeleton loader visually
|
||||||
|
await Task.Delay(2000, cancellationToken);
|
||||||
|
|
||||||
|
var fakeQuiz = new List<QuizQuestionDto>
|
||||||
|
{
|
||||||
|
new("Co było głównym centrum włoskiego Renesansu?", new List<string> { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2),
|
||||||
|
new("Kto stanowił wpływowy ród mecenasów sztuki?", new List<string> { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1),
|
||||||
|
new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List<string> { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Result.Ok(new QuizDto(fakeQuiz));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<button class="nexus-btn @Class" @onclick="OnClick" disabled="@Disabled" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string Class { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<Microsoft.AspNetCore.Components.Web.MouseEventArgs> OnClick { get; set; }
|
||||||
|
[Parameter] public bool Disabled { get; set; }
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg class="nexus-icon @Class" viewBox="0 0 24 24" fill="currentColor" width="@Size" height="@Size" @attributes="AdditionalAttributes">
|
||||||
|
@switch (Name.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "robot":
|
||||||
|
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
|
||||||
|
break;
|
||||||
|
case "play":
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
break;
|
||||||
|
case "check":
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<!-- Fallback circle -->
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Name { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string Size { get; set; } = "24";
|
||||||
|
[Parameter] public string Class { get; set; } = string.Empty;
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
.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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<div class="nexus-typography @VariantCssClass @Class" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string Class { get; set; } = string.Empty;
|
||||||
|
[Parameter] public TypographyVariant Variant { get; set; } = TypographyVariant.UI;
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private string VariantCssClass => Variant switch
|
||||||
|
{
|
||||||
|
TypographyVariant.Heading => "nexus-heading",
|
||||||
|
TypographyVariant.Ebook => "nexus-ebook",
|
||||||
|
TypographyVariant.UI => "nexus-ui",
|
||||||
|
_ => "nexus-ui"
|
||||||
|
};
|
||||||
|
|
||||||
|
public enum TypographyVariant
|
||||||
|
{
|
||||||
|
Heading,
|
||||||
|
Ebook,
|
||||||
|
UI
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@using NexusReader.Web.Client.Services
|
||||||
|
@inject IQuizStateService QuizState
|
||||||
|
|
||||||
|
<div class="ai-bubble">
|
||||||
|
<div class="ai-avatar">
|
||||||
|
<NexusIcon Name="robot" Size="32" Class="neon-glow" />
|
||||||
|
</div>
|
||||||
|
<div class="ai-content">
|
||||||
|
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Dialogue</NexusTypography>
|
||||||
|
|
||||||
|
@if (Actions != null && Actions.Any())
|
||||||
|
{
|
||||||
|
<div class="ai-actions">
|
||||||
|
@foreach (var action in Actions)
|
||||||
|
{
|
||||||
|
<NexusButton OnClick="() => HandleActionClick(action)" Disabled="@_isQuizMode">@action</NexusButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string Dialogue { get; set; } = string.Empty;
|
||||||
|
[Parameter] public List<string> Actions { get; set; } = new();
|
||||||
|
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
||||||
|
|
||||||
|
private bool _isQuizMode = false;
|
||||||
|
|
||||||
|
private async Task HandleActionClick(string action)
|
||||||
|
{
|
||||||
|
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_isQuizMode = true;
|
||||||
|
QuizState.RequestQuiz(ContextBlockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OnActionTriggered.HasDelegate)
|
||||||
|
{
|
||||||
|
await OnActionTriggered.InvokeAsync(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@using MediatR
|
||||||
|
@using NexusReader.Application.Queries.Quiz
|
||||||
|
@using NexusReader.Application.Commands.Quiz
|
||||||
|
@inject IMediator Mediator
|
||||||
|
|
||||||
|
<div class="knowledge-check-container">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<div class="skeleton-loader">
|
||||||
|
<div class="shimmer"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_quiz != null)
|
||||||
|
{
|
||||||
|
@foreach (var question in _quiz.Questions)
|
||||||
|
{
|
||||||
|
<div class="quiz-block @GetBlockClass(question)">
|
||||||
|
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@question.Question</NexusTypography>
|
||||||
|
|
||||||
|
<div class="options-container">
|
||||||
|
@for (int i = 0; i < question.Options.Count; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
<button class="quiz-option @GetOptionClass(question, index)"
|
||||||
|
@onclick="() => SelectOptionAsync(question, index)"
|
||||||
|
disabled="@_states.ContainsKey(question)">
|
||||||
|
@question.Options[index]
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private QuizDto? _quiz;
|
||||||
|
|
||||||
|
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
var query = new GetQuizQuestionsQuery(ContextBlockId);
|
||||||
|
var result = await Mediator.Send(query);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
_quiz = result.Value;
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
|
||||||
|
{
|
||||||
|
if (_states.ContainsKey(question)) return;
|
||||||
|
|
||||||
|
var cmd = new SubmitAnswerCommand(index, question.CorrectIndex);
|
||||||
|
var res = await Mediator.Send(cmd);
|
||||||
|
|
||||||
|
_states[question] = (index, res.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetBlockClass(QuizQuestionDto question)
|
||||||
|
{
|
||||||
|
if (!_states.TryGetValue(question, out var state)) return "";
|
||||||
|
return state.IsCorrect ? "state-correct" : "state-incorrect";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetOptionClass(QuizQuestionDto question, int index)
|
||||||
|
{
|
||||||
|
if (!_states.TryGetValue(question, out var state)) return "";
|
||||||
|
|
||||||
|
if (state.SelectedIndex == index)
|
||||||
|
return state.IsCorrect ? "option-correct" : "option-incorrect";
|
||||||
|
|
||||||
|
if (state.IsCorrect == false && question.CorrectIndex == index)
|
||||||
|
return "option-revealed-correct";
|
||||||
|
|
||||||
|
return "option-faded";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@using MediatR
|
||||||
|
@using NexusReader.Application.Queries.Graph
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using NexusReader.Web.Client.Services
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@inject IMediator Mediator
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject IFocusModeService FocusMode
|
||||||
|
|
||||||
|
<div class="knowledge-graph-container" id="@ContainerId">
|
||||||
|
@if (GraphData == null)
|
||||||
|
{
|
||||||
|
<div class="loading-state">
|
||||||
|
<NexusIcon Name="robot" Size="48" Class="neon-glow" />
|
||||||
|
<NexusTypography>Analyzing Chapter Nodes...</NexusTypography>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public EventCallback<string> OnNodeSelected { get; set; }
|
||||||
|
|
||||||
|
private string ContainerId = "d3-graph-container";
|
||||||
|
private GraphDataDto? GraphData;
|
||||||
|
private IJSObjectReference? _module;
|
||||||
|
private DotNetObjectReference<KnowledgeGraph>? _dotNetHelper;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
FocusMode.OnFocusModeChanged += HandleFocusSimulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
var result = await Mediator.Send(new GetKnowledgeGraphQuery());
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
GraphData = result.Value;
|
||||||
|
StateHasChanged();
|
||||||
|
await InitializeGraphAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeGraphAsync()
|
||||||
|
{
|
||||||
|
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/knowledgeGraph.js");
|
||||||
|
_dotNetHelper = DotNetObjectReference.Create(this);
|
||||||
|
await _module.InvokeVoidAsync("mount", ContainerId, GraphData, _dotNetHelper);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnNodeClicked(string nodeId)
|
||||||
|
{
|
||||||
|
if (OnNodeSelected.HasDelegate)
|
||||||
|
{
|
||||||
|
await OnNodeSelected.InvokeAsync(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void HandleFocusSimulation()
|
||||||
|
{
|
||||||
|
if (_module == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (FocusMode.IsFocusModeActive)
|
||||||
|
await _module.InvokeVoidAsync("pause");
|
||||||
|
else
|
||||||
|
await _module.InvokeVoidAsync("resume");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
FocusMode.OnFocusModeChanged -= HandleFocusSimulation;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_module is not null)
|
||||||
|
{
|
||||||
|
await _module.InvokeVoidAsync("unmount", ContainerId);
|
||||||
|
await _module.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// Ignored, the circuit is already closed
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Ignored, the circuit is already closed
|
||||||
|
}
|
||||||
|
|
||||||
|
_dotNetHelper?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
@using MediatR
|
||||||
|
@using NexusReader.Application.Queries.Reader
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using NexusReader.Web.Client.Services
|
||||||
|
@implements IDisposable
|
||||||
|
@inject IMediator Mediator
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject IThemeService ThemeService
|
||||||
|
@inject IFocusModeService FocusMode
|
||||||
|
|
||||||
|
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "")" style="padding: 2rem 0;">
|
||||||
|
<div style="display: flex; justify-content: flex-end; align-items: center; margin-bottom: 2rem; gap: 1rem; padding: 0 2rem;">
|
||||||
|
<NexusButton OnClick="@(_ => ThemeService.ToggleTheme())">
|
||||||
|
@(ThemeService.IsLightMode ? "Turn Off Lights" : "Turn On Lights")
|
||||||
|
</NexusButton>
|
||||||
|
<button @onclick="FocusMode.ToggleAsync" title="Focus Mode (F)" style="background:none; border:none; cursor:pointer; padding: 0;">
|
||||||
|
<NexusIcon Name="target" Size="28" Class="@(FocusMode.IsFocusModeActive ? "neon-glow" : "")" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (ViewModel == null)
|
||||||
|
{
|
||||||
|
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@StatusMessage</NexusTypography>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="reader-flow-container">
|
||||||
|
@foreach (var block in ViewModel.Blocks)
|
||||||
|
{
|
||||||
|
<div id="@block.Id" class="block-wrapper">
|
||||||
|
@if (block is TextSegmentBlock textSegment)
|
||||||
|
{
|
||||||
|
<NexusTypography Variant="NexusTypography.TypographyVariant.Ebook">@textSegment.Content</NexusTypography>
|
||||||
|
}
|
||||||
|
else if (block is AiActionTriggerBlock aiTrigger)
|
||||||
|
{
|
||||||
|
<AiAssistantBubble
|
||||||
|
ContextBlockId="@block.Id"
|
||||||
|
Dialogue="@aiTrigger.Dialogue"
|
||||||
|
Actions="@aiTrigger.ActionOptions"
|
||||||
|
OnActionTriggered="HandleAiAction" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ReaderPageViewModel? ViewModel;
|
||||||
|
private string StatusMessage = "Loading chapter...";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
ThemeService.OnThemeChanged += StateHasChanged;
|
||||||
|
|
||||||
|
var result = await Mediator.Send(new GetReaderPageQuery());
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
ViewModel = result.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusMessage = "Failed to load chapter content.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleAiAction(string action)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Action Triggered from Bubble: {action}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ScrollToNodeAsync(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ThemeService.OnThemeChanged -= StateHasChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@Body
|
||||||
|
|
||||||
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
|
An unhandled error has occurred.
|
||||||
|
<a href="." class="reload">Reload</a>
|
||||||
|
<span class="dismiss">🗙</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
|
||||||
|
|
||||||
|
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||||
|
<div class="components-reconnect-container">
|
||||||
|
<div class="components-rejoining-animation" aria-hidden="true">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<p class="components-reconnect-first-attempt-visible">
|
||||||
|
Rejoining the server...
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-repeated-attempt-visible">
|
||||||
|
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-failed-visible">
|
||||||
|
Failed to rejoin.<br />Please retry or reload the page.
|
||||||
|
</p>
|
||||||
|
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<p class="components-pause-visible">
|
||||||
|
The session has been paused by the server.
|
||||||
|
</p>
|
||||||
|
<p class="components-resume-failed-visible">
|
||||||
|
Failed to resume the session.<br />Please retry or reload the page.
|
||||||
|
</p>
|
||||||
|
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||||
|
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||||
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
@page "/"
|
||||||
|
@using NexusReader.Web.Client.Services
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@inject IQuizStateService QuizState
|
||||||
|
@inject IFocusModeService FocusMode
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
<PageTitle>Nexus E-Reader</PageTitle>
|
||||||
|
|
||||||
|
<div class="split-layout @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
|
||||||
|
<div class="reader-pane">
|
||||||
|
<ReaderCanvas @ref="readerCanvas" />
|
||||||
|
</div>
|
||||||
|
<div class="graph-pane">
|
||||||
|
<div class="graph-section">
|
||||||
|
<KnowledgeGraph OnNodeSelected="HandleNodeSelected" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_activeQuizBlockId))
|
||||||
|
{
|
||||||
|
<div class="quiz-section">
|
||||||
|
<KnowledgeCheck ContextBlockId="@_activeQuizBlockId" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ReaderCanvas? readerCanvas;
|
||||||
|
private string? _activeQuizBlockId;
|
||||||
|
|
||||||
|
private IJSObjectReference? _interopModule;
|
||||||
|
private IJSObjectReference? _keydownHandler;
|
||||||
|
private DotNetObjectReference<Home>? _dotNetRef;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
QuizState.OnQuizRequested += HandleQuizRequested;
|
||||||
|
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||||
|
await FocusMode.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
_interopModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/focusInterop.js");
|
||||||
|
_dotNetRef = DotNetObjectReference.Create(this);
|
||||||
|
_keydownHandler = await _interopModule.InvokeAsync<IJSObjectReference>("attachKeyboardListener", _dotNetRef);
|
||||||
|
} catch { } /* ignored dynamically */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnFocusKeypressed()
|
||||||
|
{
|
||||||
|
await FocusMode.ToggleAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleNodeSelected(string nodeId)
|
||||||
|
{
|
||||||
|
if (readerCanvas != null)
|
||||||
|
{
|
||||||
|
await readerCanvas.ScrollToNodeAsync(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleQuizRequested(string blockId)
|
||||||
|
{
|
||||||
|
_activeQuizBlockId = blockId;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
QuizState.OnQuizRequested -= HandleQuizRequested;
|
||||||
|
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||||
|
|
||||||
|
if (_interopModule != null && _keydownHandler != null)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
await _interopModule.InvokeVoidAsync("detachKeyboardListener", _keydownHandler);
|
||||||
|
await _interopModule.DisposeAsync();
|
||||||
|
await _keydownHandler.DisposeAsync();
|
||||||
|
} catch { } // Circuit disconnected catch explicitly
|
||||||
|
}
|
||||||
|
|
||||||
|
_dotNetRef?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@page "/not-found"
|
||||||
|
@layout MainLayout
|
||||||
|
|
||||||
|
<h3>Not Found</h3>
|
||||||
|
<p>Sorry, the content you are looking for does not exist.</p>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Web.Client.Services;
|
||||||
|
using NexusReader.Application.Queries.Quiz;
|
||||||
|
using NexusReader.Application;
|
||||||
|
|
||||||
|
using NexusReader.Infrastructure;
|
||||||
|
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
|
|
||||||
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.AddInfrastructure();
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
|
public sealed class FocusModeService : IFocusModeService
|
||||||
|
{
|
||||||
|
private readonly IJSRuntime _jsRuntime;
|
||||||
|
public bool IsFocusModeActive { get; private set; }
|
||||||
|
public event Action? OnFocusModeChanged;
|
||||||
|
|
||||||
|
public FocusModeService(IJSRuntime jsRuntime)
|
||||||
|
{
|
||||||
|
_jsRuntime = jsRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "nexus_focus_mode");
|
||||||
|
if (value == "true" && !IsFocusModeActive)
|
||||||
|
{
|
||||||
|
IsFocusModeActive = true;
|
||||||
|
OnFocusModeChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignored during pre-rendering or unsupported environments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ToggleAsync()
|
||||||
|
{
|
||||||
|
IsFocusModeActive = !IsFocusModeActive;
|
||||||
|
OnFocusModeChanged?.Invoke();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "nexus_focus_mode", IsFocusModeActive ? "true" : "false");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
|
public interface IFocusModeService
|
||||||
|
{
|
||||||
|
bool IsFocusModeActive { get; }
|
||||||
|
event Action? OnFocusModeChanged;
|
||||||
|
Task InitializeAsync();
|
||||||
|
Task ToggleAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
|
public interface IQuizStateService
|
||||||
|
{
|
||||||
|
string? CurrentQuizBlockId { get; }
|
||||||
|
event Action<string>? OnQuizRequested;
|
||||||
|
void RequestQuiz(string blockId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
|
public interface IThemeService
|
||||||
|
{
|
||||||
|
bool IsLightMode { get; }
|
||||||
|
event Action? OnThemeChanged;
|
||||||
|
void ToggleTheme();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
|
public sealed class QuizStateService : IQuizStateService
|
||||||
|
{
|
||||||
|
public string? CurrentQuizBlockId { get; private set; }
|
||||||
|
public event Action<string>? OnQuizRequested;
|
||||||
|
|
||||||
|
public void RequestQuiz(string blockId)
|
||||||
|
{
|
||||||
|
CurrentQuizBlockId = blockId;
|
||||||
|
OnQuizRequested?.Invoke(blockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@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
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
|
<ResourcePreloader />
|
||||||
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
|
<link rel="stylesheet" href="@Assets["NexusReader.Web.styles.css"]" />
|
||||||
|
<ImportMap />
|
||||||
|
<HeadOutlet @rendermode="InteractiveAuto" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes @rendermode="InteractiveAuto" />
|
||||||
|
<ReconnectModal />
|
||||||
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@page "/Error"
|
||||||
|
@using System.Diagnostics
|
||||||
|
|
||||||
|
<PageTitle>Error</PageTitle>
|
||||||
|
|
||||||
|
<h1 class="text-danger">Error.</h1>
|
||||||
|
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||||
|
|
||||||
|
@if (ShowRequestId)
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3>Development Mode</h3>
|
||||||
|
<p>
|
||||||
|
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||||
|
It can result in displaying sensitive information from exceptions to end users.
|
||||||
|
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||||
|
and restarting the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@code{
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private string? RequestId { get; set; }
|
||||||
|
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||||
|
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@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
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using NexusReader.Web.Components;
|
||||||
|
using NexusReader.Application;
|
||||||
|
using NexusReader.Infrastructure;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
builder.Services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents()
|
||||||
|
.AddInteractiveWebAssemblyComponents();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
|
|
||||||
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.AddInfrastructure();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseWebAssemblyDebugging();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
|
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapStaticAssets();
|
||||||
|
app.MapRazorComponents<App>()
|
||||||
|
.AddInteractiveServerRenderMode()
|
||||||
|
.AddInteractiveWebAssemblyRenderMode()
|
||||||
|
.AddAdditionalAssemblies(typeof(NexusReader.Web.Client._Imports).Assembly);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# Debug helper for NexusReader.Web.New (Blazor Server)
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# 1️⃣ Ensure the port is free before starting the server.
|
||||||
|
# 2️⃣ Starts the server project in the background.
|
||||||
|
# 3️⃣ Waits until the HTTP endpoint is reachable.
|
||||||
|
# 4️⃣ Launches Chrome (or Chromium) with a clean temporary profile
|
||||||
|
# and the remote‑debugging port (9222).
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---- configuration ------------------------------------------------
|
||||||
|
SERVER_PROJECT="src/NexusReader.Web.New/NexusReader.Web.csproj"
|
||||||
|
APP_URL="http://localhost:5104"
|
||||||
|
DEBUG_PORT=9222
|
||||||
|
TMP_PROFILE="/tmp/blazor-chrome-debug"
|
||||||
|
CHROME_CMD="google-chrome" # fallback will be tried automatically
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Export environment variables for the app
|
||||||
|
export ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
export ASPNETCORE_URLS="$APP_URL"
|
||||||
|
|
||||||
|
# Free the port if something is already listening on it
|
||||||
|
PORT=$(echo "$APP_URL" | awk -F[:/] '{print $4}')
|
||||||
|
if [ -n "$PORT" ]; then
|
||||||
|
if lsof -ti:$PORT >/dev/null 2>&1; then
|
||||||
|
echo "⚡ Port $PORT is in use – terminating existing process."
|
||||||
|
fuser -k $PORT/tcp >/dev/null 2>&1 || kill -9 $(lsof -ti:$PORT) >/dev/null 2>&1
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean old temporary profile
|
||||||
|
rm -rf "$TMP_PROFILE"
|
||||||
|
|
||||||
|
# Start the server in the background, capture its PID
|
||||||
|
# We use --no-build because we assume you just built it,
|
||||||
|
# and it prevents redundant build output in the console.
|
||||||
|
dotnet run --project "$SERVER_PROJECT" --launch-profile http &
|
||||||
|
SERVER_PID=$!
|
||||||
|
echo "🚀 Blazor Server started (PID=$SERVER_PID). Waiting for $APP_URL …"
|
||||||
|
|
||||||
|
# Simple poll loop – wait up to 30 s for the app to become reachable
|
||||||
|
MAX_WAIT=30
|
||||||
|
elapsed=0
|
||||||
|
while ! curl -s "$APP_URL" > /dev/null; do
|
||||||
|
sleep 1
|
||||||
|
((elapsed++))
|
||||||
|
if (( elapsed >= MAX_WAIT )); then
|
||||||
|
echo "❌ Timeout: $APP_URL never responded."
|
||||||
|
kill $SERVER_PID 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "✅ App is up! Launching Chrome..."
|
||||||
|
|
||||||
|
# Determine which browser binary is available
|
||||||
|
if command -v "$CHROME_CMD" > /dev/null; then :; else
|
||||||
|
if command -v chromium-browser > /dev/null; then CHROME_CMD="chromium-browser"; else
|
||||||
|
if command -v chromium > /dev/null; then CHROME_CMD="chromium"; else
|
||||||
|
echo "❌ Neither google-chrome nor chromium-browser found on PATH."
|
||||||
|
kill $SERVER_PID 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Launch Chrome with remote‑debugging and the temporary profile
|
||||||
|
# Added --headless=new if you are on a server without display,
|
||||||
|
# but keeping it interactive as requested.
|
||||||
|
"$CHROME_CMD" \
|
||||||
|
--remote-debugging-port=$DEBUG_PORT \
|
||||||
|
--user-data-dir="$TMP_PROFILE" \
|
||||||
|
--no-first-run \
|
||||||
|
--no-default-browser-check \
|
||||||
|
"$APP_URL" &
|
||||||
|
CHROME_PID=$!
|
||||||
|
|
||||||
|
echo "🌐 Chrome launched (PID=$CHROME_PID)."
|
||||||
|
echo "🔧 Open DevTools via: http://localhost:$DEBUG_PORT"
|
||||||
|
|
||||||
|
# keep the script alive until you stop it,
|
||||||
|
# forwarding signals to both processes.
|
||||||
|
trap 'echo "Stopping..."; kill $SERVER_PID $CHROME_PID 2>/dev/null' INT TERM EXIT
|
||||||
|
wait $SERVER_PID
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface ICommand : IRequest<Result>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommand<TResponse> : IRequest<Result<TResponse>>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
|
||||||
|
where TCommand : ICommand
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
|
||||||
|
where TCommand : ICommand<TResponse>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
|
||||||
|
where TQuery : IQuery<TResponse>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Queries.Quiz;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
public interface IAiGenerateQuizService
|
||||||
|
{
|
||||||
|
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user