commit f3e94c4f42ffca918689d2aaffb68e9061b491f8 Author: Debian Date: Fri Apr 24 20:27:22 2026 +0200 Initial commit: NexusArchitect Professional Workstation Overhaul diff --git a/.agents/DOD.md b/.agents/DOD.md new file mode 100644 index 0000000..56058c0 --- /dev/null +++ b/.agents/DOD.md @@ -0,0 +1,11 @@ +# Definition of Done (DoD) + +1. **Architecture Compliance:** Feature follows CQRS flow. Logic is in Handlers. Result is wrapped in `Result` from FluentResult. +2. **Modularization:** Code is in `/src`, tests in `/tests`. Module-specific logic is isolated. +3. **UI/UX Integrity:** - "Vertical Flow Check" passed (Assistant is part of the document stream, not an absolute pop-up). + - No "Layout Shift" during AI content streaming. + - Safe-area-insets respected for iOS/Android notches. +4. **Code Quality:** C# 14 syntax used (Primary Constructors, etc.). Scoped CSS (.razor.css) implemented. +5. **D3.js Performance:** JS Modules correctly disposed using `IAsyncDisposable`. +6. **Persistence:** State survives manual page refresh (Local/Session Storage integration). +7. **Mapping:** All entity-to-DTO conversions must use Mapster. \ No newline at end of file diff --git a/.agents/agents.md b/.agents/agents.md new file mode 100644 index 0000000..596860b --- /dev/null +++ b/.agents/agents.md @@ -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` via `FluentResult`. + - **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper. + - **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance. + - **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code. + - **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css). \ No newline at end of file diff --git a/.agents/skills/blazor-hybrid-bridge.md b/.agents/skills/blazor-hybrid-bridge.md new file mode 100644 index 0000000..60ac7b2 --- /dev/null +++ b/.agents/skills/blazor-hybrid-bridge.md @@ -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. \ No newline at end of file diff --git a/.agents/skills/blazor-state-performance.md b/.agents/skills/blazor-state-performance.md new file mode 100644 index 0000000..e1989ae --- /dev/null +++ b/.agents/skills/blazor-state-performance.md @@ -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. \ No newline at end of file diff --git a/.agents/skills/nexus-clean-architecture.md b/.agents/skills/nexus-clean-architecture.md new file mode 100644 index 0000000..e0fd102 --- /dev/null +++ b/.agents/skills/nexus-clean-architecture.md @@ -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>`. + - **Commands**: State-changing operations, return `Task` or `Task>`. + - **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. \ No newline at end of file diff --git a/.agents/skills/nexus-graph-d3.md b/.agents/skills/nexus-graph-d3.md new file mode 100644 index 0000000..bc008a1 --- /dev/null +++ b/.agents/skills/nexus-graph-d3.md @@ -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`. \ No newline at end of file diff --git a/.agents/skills/nexus-ui-engine.md b/.agents/skills/nexus-ui-engine.md new file mode 100644 index 0000000..1cd795a --- /dev/null +++ b/.agents/skills/nexus-ui-engine.md @@ -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 (` + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string Class { get; set; } = string.Empty; + [Parameter] public EventCallback OnClick { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor.css new file mode 100644 index 0000000..f8e54d3 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusButton.razor.css @@ -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; +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor new file mode 100644 index 0000000..e687adc --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor @@ -0,0 +1,25 @@ + + @switch (Name.ToLowerInvariant()) + { + case "robot": + + break; + case "play": + + break; + case "check": + + break; + default: + + + break; + } + + +@code { + [Parameter] public string Name { get; set; } = string.Empty; + [Parameter] public string Size { get; set; } = "24"; + [Parameter] public string Class { get; set; } = string.Empty; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor.css new file mode 100644 index 0000000..53024c6 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusIcon.razor.css @@ -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)); +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor new file mode 100644 index 0000000..2b3ab32 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor @@ -0,0 +1,25 @@ +
+ @ChildContent +
+ +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string Class { get; set; } = string.Empty; + [Parameter] public TypographyVariant Variant { get; set; } = TypographyVariant.UI; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + private string VariantCssClass => Variant switch + { + TypographyVariant.Heading => "nexus-heading", + TypographyVariant.Ebook => "nexus-ebook", + TypographyVariant.UI => "nexus-ui", + _ => "nexus-ui" + }; + + public enum TypographyVariant + { + Heading, + Ebook, + UI + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor.css new file mode 100644 index 0000000..62dfd80 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Atoms/NexusTypography.razor.css @@ -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); +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor new file mode 100644 index 0000000..1f5e287 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor @@ -0,0 +1,44 @@ +@using NexusReader.Web.Client.Services +@inject IQuizStateService QuizState + +
+
+ +
+
+ @Dialogue + + @if (Actions != null && Actions.Any()) + { +
+ @foreach (var action in Actions) + { + @action + } +
+ } +
+
+ +@code { + [Parameter] public string ContextBlockId { get; set; } = string.Empty; + [Parameter] public string Dialogue { get; set; } = string.Empty; + [Parameter] public List Actions { get; set; } = new(); + [Parameter] public EventCallback OnActionTriggered { get; set; } + + private bool _isQuizMode = false; + + private async Task HandleActionClick(string action) + { + if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase)) + { + _isQuizMode = true; + QuizState.RequestQuiz(ContextBlockId); + } + + if (OnActionTriggered.HasDelegate) + { + await OnActionTriggered.InvokeAsync(action); + } + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor.css new file mode 100644 index 0000000..2d7079c --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/AiAssistantBubble.razor.css @@ -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; +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor new file mode 100644 index 0000000..99fcf49 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor @@ -0,0 +1,84 @@ +@using MediatR +@using NexusReader.Application.Queries.Quiz +@using NexusReader.Application.Commands.Quiz +@inject IMediator Mediator + +
+ @if (_isLoading) + { +
+
+
+ } + else if (_quiz != null) + { + @foreach (var question in _quiz.Questions) + { +
+ @question.Question + +
+ @for (int i = 0; i < question.Options.Count; i++) + { + var index = i; + + } +
+
+ } + } +
+ +@code { + [Parameter] public string ContextBlockId { get; set; } = string.Empty; + + private bool _isLoading = true; + private QuizDto? _quiz; + + private Dictionary _states = new(); + + protected override async Task OnInitializedAsync() + { + _isLoading = true; + var query = new GetQuizQuestionsQuery(ContextBlockId); + var result = await Mediator.Send(query); + + if (result.IsSuccess) + _quiz = result.Value; + + _isLoading = false; + } + + private async Task SelectOptionAsync(QuizQuestionDto question, int index) + { + if (_states.ContainsKey(question)) return; + + var cmd = new SubmitAnswerCommand(index, question.CorrectIndex); + var res = await Mediator.Send(cmd); + + _states[question] = (index, res.IsSuccess); + } + + private string GetBlockClass(QuizQuestionDto question) + { + if (!_states.TryGetValue(question, out var state)) return ""; + return state.IsCorrect ? "state-correct" : "state-incorrect"; + } + + private string GetOptionClass(QuizQuestionDto question, int index) + { + if (!_states.TryGetValue(question, out var state)) return ""; + + if (state.SelectedIndex == index) + return state.IsCorrect ? "option-correct" : "option-incorrect"; + + if (state.IsCorrect == false && question.CorrectIndex == index) + return "option-revealed-correct"; + + return "option-faded"; + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor.css new file mode 100644 index 0000000..24cd993 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Molecules/KnowledgeCheck.razor.css @@ -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; +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor new file mode 100644 index 0000000..0f13b71 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor @@ -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 + +
+ @if (GraphData == null) + { +
+ + Analyzing Chapter Nodes... +
+ } +
+ +@code { + [Parameter] public EventCallback OnNodeSelected { get; set; } + + private string ContainerId = "d3-graph-container"; + private GraphDataDto? GraphData; + private IJSObjectReference? _module; + private DotNetObjectReference? _dotNetHelper; + + protected override void OnInitialized() + { + FocusMode.OnFocusModeChanged += HandleFocusSimulation; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + var result = await Mediator.Send(new GetKnowledgeGraphQuery()); + if (result.IsSuccess) + { + GraphData = result.Value; + StateHasChanged(); + await InitializeGraphAsync(); + } + } + } + + private async Task InitializeGraphAsync() + { + _module = await JS.InvokeAsync("import", "./js/knowledgeGraph.js"); + _dotNetHelper = DotNetObjectReference.Create(this); + await _module.InvokeVoidAsync("mount", ContainerId, GraphData, _dotNetHelper); + } + + [JSInvokable] + public async Task OnNodeClicked(string nodeId) + { + if (OnNodeSelected.HasDelegate) + { + await OnNodeSelected.InvokeAsync(nodeId); + } + } + + private async void HandleFocusSimulation() + { + if (_module == null) return; + try + { + if (FocusMode.IsFocusModeActive) + await _module.InvokeVoidAsync("pause"); + else + await _module.InvokeVoidAsync("resume"); + } + catch { } + } + + public async ValueTask DisposeAsync() + { + FocusMode.OnFocusModeChanged -= HandleFocusSimulation; + try + { + if (_module is not null) + { + await _module.InvokeVoidAsync("unmount", ContainerId); + await _module.DisposeAsync(); + } + } + catch (JSDisconnectedException) + { + // Ignored, the circuit is already closed + } + catch (TaskCanceledException) + { + // Ignored, the circuit is already closed + } + + _dotNetHelper?.Dispose(); + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor.css new file mode 100644 index 0000000..e23804e --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/KnowledgeGraph.razor.css @@ -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; +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor new file mode 100644 index 0000000..4040495 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor @@ -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 + +
+
+ + @(ThemeService.IsLightMode ? "Turn Off Lights" : "Turn On Lights") + + +
+ + @if (ViewModel == null) + { + @StatusMessage + } + else + { +
+ @foreach (var block in ViewModel.Blocks) + { +
+ @if (block is TextSegmentBlock textSegment) + { + @textSegment.Content + } + else if (block is AiActionTriggerBlock aiTrigger) + { + + } +
+ } +
+ } +
+ +@code { + private ReaderPageViewModel? ViewModel; + private string StatusMessage = "Loading chapter..."; + + protected override async Task OnInitializedAsync() + { + ThemeService.OnThemeChanged += StateHasChanged; + + var result = await Mediator.Send(new GetReaderPageQuery()); + if (result.IsSuccess) + { + ViewModel = result.Value; + } + else + { + StatusMessage = "Failed to load chapter content."; + } + } + + private void HandleAiAction(string action) + { + Console.WriteLine($"Action Triggered from Bubble: {action}"); + } + + public async Task ScrollToNodeAsync(string id) + { + try + { + await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});"); + } + catch { } + } + + public void Dispose() + { + ThemeService.OnThemeChanged -= StateHasChanged; + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor.css b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor.css new file mode 100644 index 0000000..053d648 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Components/Organisms/ReaderCanvas.razor.css @@ -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; +} diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor b/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor new file mode 100644 index 0000000..50596c7 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor @@ -0,0 +1,10 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor.css b/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor.css new file mode 100644 index 0000000..3e40fb8 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Layout/MainLayout.razor.css @@ -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; + } diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor new file mode 100644 index 0000000..11132ba --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

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

+ +

+ The session has been paused by the server. +

+

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

+ +
+
diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.css b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..af38c96 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.css @@ -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; + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.js b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..1609d42 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Layout/ReconnectModal.razor.js @@ -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(); + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj b/ejajBook/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj new file mode 100644 index 0000000..a0edfaf --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + Default + true + + + + + + + + + + + + + diff --git a/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor b/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor new file mode 100644 index 0000000..e54cc5c --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor @@ -0,0 +1,91 @@ +@page "/" +@using NexusReader.Web.Client.Services +@implements IAsyncDisposable +@inject IQuizStateService QuizState +@inject IFocusModeService FocusMode +@inject IJSRuntime JS +Nexus E-Reader + +
+
+ +
+
+
+ +
+ + @if (!string.IsNullOrEmpty(_activeQuizBlockId)) + { +
+ +
+ } +
+
+ +@code { + private ReaderCanvas? readerCanvas; + private string? _activeQuizBlockId; + + private IJSObjectReference? _interopModule; + private IJSObjectReference? _keydownHandler; + private DotNetObjectReference? _dotNetRef; + + protected override async Task OnInitializedAsync() + { + QuizState.OnQuizRequested += HandleQuizRequested; + FocusMode.OnFocusModeChanged += StateHasChanged; + await FocusMode.InitializeAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try { + _interopModule = await JS.InvokeAsync("import", "./js/focusInterop.js"); + _dotNetRef = DotNetObjectReference.Create(this); + _keydownHandler = await _interopModule.InvokeAsync("attachKeyboardListener", _dotNetRef); + } catch { } /* ignored dynamically */ + } + } + + [JSInvokable] + public async Task OnFocusKeypressed() + { + await FocusMode.ToggleAsync(); + StateHasChanged(); + } + + private async Task HandleNodeSelected(string nodeId) + { + if (readerCanvas != null) + { + await readerCanvas.ScrollToNodeAsync(nodeId); + } + } + + private void HandleQuizRequested(string blockId) + { + _activeQuizBlockId = blockId; + StateHasChanged(); + } + + public async ValueTask DisposeAsync() + { + QuizState.OnQuizRequested -= HandleQuizRequested; + FocusMode.OnFocusModeChanged -= StateHasChanged; + + if (_interopModule != null && _keydownHandler != null) + { + try { + await _interopModule.InvokeVoidAsync("detachKeyboardListener", _keydownHandler); + await _interopModule.DisposeAsync(); + await _keydownHandler.DisposeAsync(); + } catch { } // Circuit disconnected catch explicitly + } + + _dotNetRef?.Dispose(); + } +} diff --git a/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor.css b/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor.css new file mode 100644 index 0000000..cf981ac --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Pages/Home.razor.css @@ -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; +} diff --git a/ejajBook/src/NexusReader.Web.Client/Pages/NotFound.razor b/ejajBook/src/NexusReader.Web.Client/Pages/NotFound.razor new file mode 100644 index 0000000..f74a7a3 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.Client/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

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

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

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

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

+

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

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/ejajBook/src/NexusReader.Web.New/Components/_Imports.razor b/ejajBook/src/NexusReader.Web.New/Components/_Imports.razor new file mode 100644 index 0000000..8a4ee67 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.New/Components/_Imports.razor @@ -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 diff --git a/ejajBook/src/NexusReader.Web.New/NexusReader.Web.csproj b/ejajBook/src/NexusReader.Web.New/NexusReader.Web.csproj new file mode 100644 index 0000000..8505eb3 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.New/NexusReader.Web.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/ejajBook/src/NexusReader.Web.New/Program.cs b/ejajBook/src/NexusReader.Web.New/Program.cs new file mode 100644 index 0000000..5d50947 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.New/Program.cs @@ -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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(NexusReader.Web.Client._Imports).Assembly); + +app.Run(); diff --git a/ejajBook/src/NexusReader.Web.New/Properties/launchSettings.json b/ejajBook/src/NexusReader.Web.New/Properties/launchSettings.json new file mode 100644 index 0000000..384b224 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.New/Properties/launchSettings.json @@ -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" + } + } + } + } diff --git a/ejajBook/src/NexusReader.Web.New/appsettings.Development.json b/ejajBook/src/NexusReader.Web.New/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.New/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ejajBook/src/NexusReader.Web.New/appsettings.json b/ejajBook/src/NexusReader.Web.New/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.New/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/ejajBook/src/NexusReader.Web.New/wwwroot/app.css b/ejajBook/src/NexusReader.Web.New/wwwroot/app.css new file mode 100644 index 0000000..f39c635 --- /dev/null +++ b/ejajBook/src/NexusReader.Web.New/wwwroot/app.css @@ -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; +} \ No newline at end of file diff --git a/run-debug.sh b/run-debug.sh new file mode 100755 index 0000000..c48a24b --- /dev/null +++ b/run-debug.sh @@ -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 diff --git a/src/NexusReader.Application/Abstractions/Messaging/ICommand.cs b/src/NexusReader.Application/Abstractions/Messaging/ICommand.cs new file mode 100644 index 0000000..507d6b7 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Messaging/ICommand.cs @@ -0,0 +1,12 @@ +using FluentResults; +using MediatR; + +namespace NexusReader.Application.Abstractions.Messaging; + +public interface ICommand : IRequest +{ +} + +public interface ICommand : IRequest> +{ +} diff --git a/src/NexusReader.Application/Abstractions/Messaging/ICommandHandler.cs b/src/NexusReader.Application/Abstractions/Messaging/ICommandHandler.cs new file mode 100644 index 0000000..bf6b464 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Messaging/ICommandHandler.cs @@ -0,0 +1,14 @@ +using FluentResults; +using MediatR; + +namespace NexusReader.Application.Abstractions.Messaging; + +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand +{ +} + +public interface ICommandHandler : IRequestHandler> + where TCommand : ICommand +{ +} diff --git a/src/NexusReader.Application/Abstractions/Messaging/IQuery.cs b/src/NexusReader.Application/Abstractions/Messaging/IQuery.cs new file mode 100644 index 0000000..ff09669 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Messaging/IQuery.cs @@ -0,0 +1,8 @@ +using FluentResults; +using MediatR; + +namespace NexusReader.Application.Abstractions.Messaging; + +public interface IQuery : IRequest> +{ +} diff --git a/src/NexusReader.Application/Abstractions/Messaging/IQueryHandler.cs b/src/NexusReader.Application/Abstractions/Messaging/IQueryHandler.cs new file mode 100644 index 0000000..3bce270 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Messaging/IQueryHandler.cs @@ -0,0 +1,9 @@ +using FluentResults; +using MediatR; + +namespace NexusReader.Application.Abstractions.Messaging; + +public interface IQueryHandler : IRequestHandler> + where TQuery : IQuery +{ +} diff --git a/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs b/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs new file mode 100644 index 0000000..7a114b8 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs @@ -0,0 +1,9 @@ +using FluentResults; +using NexusReader.Application.Queries.Quiz; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IAiGenerateQuizService +{ + Task> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs new file mode 100644 index 0000000..d9a4bff --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs @@ -0,0 +1,12 @@ +using FluentResults; + +namespace NexusReader.Application.Abstractions.Services; + +public interface INativeStorageService +{ + Result SaveString(string key, string value); + Result GetString(string key); + Result SaveBool(string key, bool value); + Result GetBool(string key, bool defaultValue = false); + Result Remove(string key); +} diff --git a/src/NexusReader.Application/Abstractions/Services/IPlatformService.cs b/src/NexusReader.Application/Abstractions/Services/IPlatformService.cs new file mode 100644 index 0000000..eff7ebf --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IPlatformService.cs @@ -0,0 +1,33 @@ +using FluentResults; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IPlatformService +{ + Task VibrateSuccessAsync(); + Task VibrateErrorAsync(); + Task VibrateAsync(int milliseconds); + Result GetDeviceContext(); +} + +public record DeviceContext( + string Model, + string Manufacturer, + DeviceType DeviceType, + DisplayOrientation Orientation +); + +public enum DeviceType +{ + Unknown, + Phone, + Tablet, + Desktop +} + +public enum DisplayOrientation +{ + Unknown, + Portrait, + Landscape +} diff --git a/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommand.cs b/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommand.cs new file mode 100644 index 0000000..59a608e --- /dev/null +++ b/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommand.cs @@ -0,0 +1,5 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Commands.Quiz; + +public record SubmitAnswerCommand(int SelectedIndex, int CorrectIndex) : ICommand; diff --git a/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommandHandler.cs b/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommandHandler.cs new file mode 100644 index 0000000..0f68b07 --- /dev/null +++ b/src/NexusReader.Application/Commands/Quiz/SubmitAnswerCommandHandler.cs @@ -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 +{ + private readonly IPlatformService _platformService; + + public SubmitAnswerCommandHandler(IPlatformService platformService) + { + _platformService = platformService; + } + + public async Task Handle(SubmitAnswerCommand request, CancellationToken cancellationToken) + { + if (request.SelectedIndex == request.CorrectIndex) + { + await _platformService.VibrateAsync(50); + return Result.Ok(); + } + + return Result.Fail("Incorrect answer."); + } +} diff --git a/src/NexusReader.Application/Common/AppJsonContext.cs b/src/NexusReader.Application/Common/AppJsonContext.cs new file mode 100644 index 0000000..99d0896 --- /dev/null +++ b/src/NexusReader.Application/Common/AppJsonContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using NexusReader.Application.Queries.Graph; + +namespace NexusReader.Application.Common; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(GraphNodeDto))] +[JsonSerializable(typeof(GraphLinkDto))] +[JsonSerializable(typeof(GraphDataDto))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +public partial class AppJsonContext : JsonSerializerContext +{ +} diff --git a/src/NexusReader.Application/DependencyInjection.cs b/src/NexusReader.Application/DependencyInjection.cs new file mode 100644 index 0000000..f897249 --- /dev/null +++ b/src/NexusReader.Application/DependencyInjection.cs @@ -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; + } +} diff --git a/src/NexusReader.Application/Mappings/MappingConfig.cs b/src/NexusReader.Application/Mappings/MappingConfig.cs new file mode 100644 index 0000000..b21d5c5 --- /dev/null +++ b/src/NexusReader.Application/Mappings/MappingConfig.cs @@ -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; + + // Manual registration for AOT (or use Source Generator) + // config.NewConfig(); + + services.AddSingleton(config); + services.AddScoped(); + + return services; + } +} diff --git a/src/NexusReader.Application/NexusReader.Application.csproj b/src/NexusReader.Application/NexusReader.Application.csproj new file mode 100644 index 0000000..24bfe05 --- /dev/null +++ b/src/NexusReader.Application/NexusReader.Application.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs new file mode 100644 index 0000000..d95619a --- /dev/null +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs @@ -0,0 +1,5 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.Graph; + +public record GetKnowledgeGraphQuery : IQuery; diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs new file mode 100644 index 0000000..b8e7e07 --- /dev/null +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs @@ -0,0 +1,30 @@ +using FluentResults; +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.Graph; + +internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler +{ + public Task> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken) + { + var nodes = new List + { + new("renesans-intro", "Renesans", "Concept"), + new("florencja", "Florencja", "Location"), + new("medyceusze", "Medyceusze", "Entity"), + new("da-vinci-ai", "Leonardo da Vinci", "Person"), + new("humanizm", "Humanizm", "Concept") + }; + + var links = new List + { + new("renesans-intro", "florencja", 1), + new("florencja", "medyceusze", 2), + new("medyceusze", "da-vinci-ai", 3), + new("renesans-intro", "humanizm", 1), + new("da-vinci-ai", "humanizm", 2) + }; + + return Task.FromResult(Result.Ok(new GraphDataDto(nodes, links))); + } +} diff --git a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs new file mode 100644 index 0000000..00a0ce3 --- /dev/null +++ b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs @@ -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 Nodes, List Links); diff --git a/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs b/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs new file mode 100644 index 0000000..6ae3617 --- /dev/null +++ b/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs @@ -0,0 +1,5 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.Quiz; + +public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery; diff --git a/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs b/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs new file mode 100644 index 0000000..e034e7d --- /dev/null +++ b/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs @@ -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 +{ + private readonly IAiGenerateQuizService _aiService; + + public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService) + { + _aiService = aiService; + } + + public async Task> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken) + { + return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken); + } +} diff --git a/src/NexusReader.Application/Queries/Quiz/QuizViewModels.cs b/src/NexusReader.Application/Queries/Quiz/QuizViewModels.cs new file mode 100644 index 0000000..a236c5a --- /dev/null +++ b/src/NexusReader.Application/Queries/Quiz/QuizViewModels.cs @@ -0,0 +1,4 @@ +namespace NexusReader.Application.Queries.Quiz; + +public record QuizQuestionDto(string Question, List Options, int CorrectIndex); +public record QuizDto(List Questions); diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs new file mode 100644 index 0000000..b97aa27 --- /dev/null +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs @@ -0,0 +1,5 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.Reader; + +public record GetReaderPageQuery : IQuery; diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs new file mode 100644 index 0000000..28f040d --- /dev/null +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs @@ -0,0 +1,20 @@ +using FluentResults; +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.Reader; + +internal sealed class GetReaderPageQueryHandler : IQueryHandler +{ + public Task> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) + { + var blocks = new List + { + new TextSegmentBlock("renesans-intro", "Renesans, nazywany również odrodzeniem, to epoka w historii kultury europejskiej, która zapoczątkowała odejście od średniowiecznego teocentryzmu na rzecz humanizmu. Narodził się we Włoszech, a dokładnie we Florencji, w XV wieku, skąd promieniował na całą Europę."), + new TextSegmentBlock("medyceusze", "Głównym mecenasem sztuki i nauki we Florencji był potężny ród Medyceuszy. To dzięki ich wsparciu miasto stało się kolebką nowożytnej myśli, gromadząc wokół siebie najwybitniejsze umysły tamtych czasów."), + new AiActionTriggerBlock("da-vinci-ai", "Leonardo da Vinci był jednym z najważniejszych twórców tego okresu. Czy chciałbyś dowiedzieć się więcej o jego najważniejszych wynalazkach, czy wolisz sprawdzić swoją dotychczasową wiedzę?", new List { "Pokaż więcej", "Rozwiąż quiz" }), + new TextSegmentBlock("leonardo-detail", "Człowiek renesansu, uosabiany właśnie przez Leonarda, był wszechstronnie wykształcony. Interesował się sztuką, inżynierią, anatomią i filozofią, stawiając jednostkę w centrum wszechświata.") + }; + + return Task.FromResult(Result.Ok(new ReaderPageViewModel(blocks))); + } +} diff --git a/src/NexusReader.Application/Queries/Reader/ViewModels.cs b/src/NexusReader.Application/Queries/Reader/ViewModels.cs new file mode 100644 index 0000000..fb06490 --- /dev/null +++ b/src/NexusReader.Application/Queries/Reader/ViewModels.cs @@ -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 ActionOptions) : ContentBlock(Id); + +public record ReaderPageViewModel(List Blocks); diff --git a/src/NexusReader.Application/Queries/System/GetInitializationStatusQuery.cs b/src/NexusReader.Application/Queries/System/GetInitializationStatusQuery.cs new file mode 100644 index 0000000..45f94cf --- /dev/null +++ b/src/NexusReader.Application/Queries/System/GetInitializationStatusQuery.cs @@ -0,0 +1,5 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.System; + +public record GetInitializationStatusQuery : IQuery; diff --git a/src/NexusReader.Application/Queries/System/GetInitializationStatusQueryHandler.cs b/src/NexusReader.Application/Queries/System/GetInitializationStatusQueryHandler.cs new file mode 100644 index 0000000..cd067e8 --- /dev/null +++ b/src/NexusReader.Application/Queries/System/GetInitializationStatusQueryHandler.cs @@ -0,0 +1,12 @@ +using FluentResults; +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.System; + +internal sealed class GetInitializationStatusQueryHandler : IQueryHandler +{ + public Task> Handle(GetInitializationStatusQuery request, CancellationToken cancellationToken) + { + return Task.FromResult(Result.Ok("Nexus E-Reader Application is fully initialized and operational.")); + } +} diff --git a/src/NexusReader.Domain/NexusReader.Domain.csproj b/src/NexusReader.Domain/NexusReader.Domain.csproj new file mode 100644 index 0000000..6d36c6d --- /dev/null +++ b/src/NexusReader.Domain/NexusReader.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/NexusReader.Infrastructure.Mobile/Class1.cs b/src/NexusReader.Infrastructure.Mobile/Class1.cs new file mode 100644 index 0000000..b34b54a --- /dev/null +++ b/src/NexusReader.Infrastructure.Mobile/Class1.cs @@ -0,0 +1,6 @@ +namespace NexusReader.Infrastructure.Mobile; + +public class Class1 +{ + +} diff --git a/src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj b/src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj new file mode 100644 index 0000000..e29004b --- /dev/null +++ b/src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj @@ -0,0 +1,17 @@ + + + + net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + true + true + enable + enable + + + + + + + diff --git a/src/NexusReader.Infrastructure.Mobile/Services/MauiPlatformService.cs b/src/NexusReader.Infrastructure.Mobile/Services/MauiPlatformService.cs new file mode 100644 index 0000000..20b04c2 --- /dev/null +++ b/src/NexusReader.Infrastructure.Mobile/Services/MauiPlatformService.cs @@ -0,0 +1,84 @@ +using FluentResults; +using Microsoft.Maui.Devices; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Infrastructure.Mobile.Services; + +public sealed class MauiPlatformService : IPlatformService +{ + public async Task VibrateSuccessAsync() + { + try + { + HapticFeedback.Default.Perform(HapticFeedbackType.Click); + await Task.Delay(100); + HapticFeedback.Default.Perform(HapticFeedbackType.Click); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task VibrateErrorAsync() + { + try + { + HapticFeedback.Default.Perform(HapticFeedbackType.LongPress); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task VibrateAsync(int milliseconds) + { + try + { + HapticFeedback.Default.Perform(HapticFeedbackType.Click); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result GetDeviceContext() + { + try + { + var device = DeviceInfo.Current; + var display = DeviceDisplay.Current.MainDisplayInfo; + + return Result.Ok(new DeviceContext( + device.Model, + device.Manufacturer, + MapDeviceType(device.Idiom), + MapOrientation(display.Orientation) + )); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + private static NexusReader.Application.Abstractions.Services.DeviceType MapDeviceType(DeviceIdiom idiom) + { + if (idiom == DeviceIdiom.Phone) return NexusReader.Application.Abstractions.Services.DeviceType.Phone; + if (idiom == DeviceIdiom.Tablet) return NexusReader.Application.Abstractions.Services.DeviceType.Tablet; + if (idiom == DeviceIdiom.Desktop) return NexusReader.Application.Abstractions.Services.DeviceType.Desktop; + return NexusReader.Application.Abstractions.Services.DeviceType.Unknown; + } + + private static NexusReader.Application.Abstractions.Services.DisplayOrientation MapOrientation(Microsoft.Maui.Devices.DisplayOrientation orientation) => orientation switch + { + Microsoft.Maui.Devices.DisplayOrientation.Portrait => NexusReader.Application.Abstractions.Services.DisplayOrientation.Portrait, + Microsoft.Maui.Devices.DisplayOrientation.Landscape => NexusReader.Application.Abstractions.Services.DisplayOrientation.Landscape, + _ => NexusReader.Application.Abstractions.Services.DisplayOrientation.Unknown + }; +} diff --git a/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs b/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs new file mode 100644 index 0000000..c46fa8e --- /dev/null +++ b/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs @@ -0,0 +1,71 @@ +using FluentResults; +using Microsoft.Maui.Storage; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Infrastructure.Mobile.Services; + +public sealed class MauiStorageService : INativeStorageService +{ + public Result SaveString(string key, string value) + { + try + { + Preferences.Default.Set(key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result GetString(string key) + { + try + { + return Result.Ok(Preferences.Default.Get(key, (string?)null)); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result SaveBool(string key, bool value) + { + try + { + Preferences.Default.Set(key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result GetBool(string key, bool defaultValue = false) + { + try + { + return Result.Ok(Preferences.Default.Get(key, defaultValue)); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result Remove(string key) + { + try + { + Preferences.Default.Remove(key); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..bcb1f70 --- /dev/null +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -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(); + return services; + } +} diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj new file mode 100644 index 0000000..f79590e --- /dev/null +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs b/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs new file mode 100644 index 0000000..22b9d9d --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs @@ -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> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default) + { + // 2000ms delay to highlight Skeleton loader visually + await Task.Delay(2000, cancellationToken); + + var fakeQuiz = new List + { + new("Co było głównym centrum włoskiego Renesansu?", new List { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2), + new("Kto stanowił wpływowy ród mecenasów sztuki?", new List { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1), + new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2) + }; + + return Result.Ok(new QuizDto(fakeQuiz)); + } +} diff --git a/src/NexusReader.Maui/App.xaml b/src/NexusReader.Maui/App.xaml new file mode 100644 index 0000000..8962045 --- /dev/null +++ b/src/NexusReader.Maui/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/NexusReader.Maui/App.xaml.cs b/src/NexusReader.Maui/App.xaml.cs new file mode 100644 index 0000000..28db06e --- /dev/null +++ b/src/NexusReader.Maui/App.xaml.cs @@ -0,0 +1,11 @@ +namespace NexusReader.Maui; + +public partial class App : Microsoft.Maui.Controls.Application +{ + public App() + { + InitializeComponent(); + + MainPage = new MainPage(); + } +} diff --git a/src/NexusReader.Maui/Main.razor b/src/NexusReader.Maui/Main.razor new file mode 100644 index 0000000..021c022 --- /dev/null +++ b/src/NexusReader.Maui/Main.razor @@ -0,0 +1,14 @@ +@using Microsoft.AspNetCore.Components.Routing +@using NexusReader.UI.Shared + + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/NexusReader.Maui/MainPage.xaml b/src/NexusReader.Maui/MainPage.xaml new file mode 100644 index 0000000..a37d249 --- /dev/null +++ b/src/NexusReader.Maui/MainPage.xaml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/NexusReader.Maui/MainPage.xaml.cs b/src/NexusReader.Maui/MainPage.xaml.cs new file mode 100644 index 0000000..5c3c8b7 --- /dev/null +++ b/src/NexusReader.Maui/MainPage.xaml.cs @@ -0,0 +1,9 @@ +namespace NexusReader.Maui; + +public partial class MainPage : ContentPage +{ + public MainPage() + { + InitializeComponent(); + } +} diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs new file mode 100644 index 0000000..6282e5d --- /dev/null +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Infrastructure.Mobile.Services; +using NexusReader.UI.Shared.Services; + +namespace NexusReader.Maui; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + }); + + builder.Services.AddMauiBlazorWebView(); + +#if DEBUG + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + + // Infrastructure + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Shared UI State + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + return builder.Build(); + } +} diff --git a/src/NexusReader.Maui/NexusReader.Maui.csproj b/src/NexusReader.Maui/NexusReader.Maui.csproj new file mode 100644 index 0000000..353fdfb --- /dev/null +++ b/src/NexusReader.Maui/NexusReader.Maui.csproj @@ -0,0 +1,36 @@ + + + + net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + Nexus E-Reader + com.nexus.ereader + Exe + true + true + true + enable + enable + 1.0 + 1 + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + diff --git a/src/NexusReader.Maui/Resources/Styles/Colors.xaml b/src/NexusReader.Maui/Resources/Styles/Colors.xaml new file mode 100644 index 0000000..09b6e0c --- /dev/null +++ b/src/NexusReader.Maui/Resources/Styles/Colors.xaml @@ -0,0 +1,29 @@ + + + + + #512BD4 + #DFD8F7 + #2B0B98 + + White + Black + #E1E1E1 + #C8C8C8 + #ACACAC + #919191 + #6E6E6E + #404040 + #212121 + #121212 + + + + + + + + #0A0A0A + diff --git a/src/NexusReader.Maui/Resources/Styles/Styles.xaml b/src/NexusReader.Maui/Resources/Styles/Styles.xaml new file mode 100644 index 0000000..d3a69c9 --- /dev/null +++ b/src/NexusReader.Maui/Resources/Styles/Styles.xaml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/src/NexusReader.Maui/_Imports.razor b/src/NexusReader.Maui/_Imports.razor new file mode 100644 index 0000000..7631c85 --- /dev/null +++ b/src/NexusReader.Maui/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using NexusReader.Maui +@using NexusReader.UI.Shared +@using NexusReader.UI.Shared.Components +@using NexusReader.UI.Shared.Layout diff --git a/src/NexusReader.Maui/wwwroot/index.html b/src/NexusReader.Maui/wwwroot/index.html new file mode 100644 index 0000000..544600a --- /dev/null +++ b/src/NexusReader.Maui/wwwroot/index.html @@ -0,0 +1,27 @@ + + + + + + NexusReader.Maui + + + + + + + +
Loading...
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusButton.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusButton.razor new file mode 100644 index 0000000..dbe84b1 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusButton.razor @@ -0,0 +1,11 @@ + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string Class { get; set; } = string.Empty; + [Parameter] public EventCallback OnClick { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusButton.razor.css b/src/NexusReader.UI.Shared/Components/Atoms/NexusButton.razor.css new file mode 100644 index 0000000..f8e54d3 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusButton.razor.css @@ -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; +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor new file mode 100644 index 0000000..1e48266 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor @@ -0,0 +1,40 @@ + + @switch (Name.ToLowerInvariant()) + { + case "robot": + + break; + case "play": + + break; + case "check": + + break; + case "search": + + break; + case "message-square": + + break; + case "settings": + + break; + case "bookmark": + + break; + case "target": + + break; + default: + + + break; + } + + +@code { + [Parameter] public string Name { get; set; } = string.Empty; + [Parameter] public string Size { get; set; } = "24"; + [Parameter] public string Class { get; set; } = string.Empty; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor.css b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor.css new file mode 100644 index 0000000..53024c6 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor.css @@ -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)); +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusTypography.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusTypography.razor new file mode 100644 index 0000000..2b3ab32 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusTypography.razor @@ -0,0 +1,25 @@ +
+ @ChildContent +
+ +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string Class { get; set; } = string.Empty; + [Parameter] public TypographyVariant Variant { get; set; } = TypographyVariant.UI; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + private string VariantCssClass => Variant switch + { + TypographyVariant.Heading => "nexus-heading", + TypographyVariant.Ebook => "nexus-ebook", + TypographyVariant.UI => "nexus-ui", + _ => "nexus-ui" + }; + + public enum TypographyVariant + { + Heading, + Ebook, + UI + } +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusTypography.razor.css b/src/NexusReader.UI.Shared/Components/Atoms/NexusTypography.razor.css new file mode 100644 index 0000000..41eee76 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusTypography.razor.css @@ -0,0 +1,27 @@ +.nexus-typography { + margin: 0; +} + +.nexus-heading { + font-family: var(--nexus-font-sans); + font-size: 2rem; + font-weight: 600; + color: white; + margin-bottom: 1rem; +} + +.nexus-ebook { + font-family: var(--nexus-font-serif); + font-size: 1.125rem; + line-height: 1.65; + color: #1a1a1a; + margin-bottom: 1.5rem; + font-weight: 300; +} + + +.nexus-ui { + font-family: var(--nexus-font-sans); + font-size: 1rem; + color: #cccccc; +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor new file mode 100644 index 0000000..6bee4ae --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor @@ -0,0 +1,49 @@ +@using NexusReader.UI.Shared.Services +@inject IQuizStateService QuizState + +
+
+
+
+ +
+ E-Czytnik + Asystent AI +
+
+
+ @Dialogue + +
+ + +
+ +
+
+
+
+ + +@code { + [Parameter] public string ContextBlockId { get; set; } = string.Empty; + [Parameter] public string Dialogue { get; set; } = string.Empty; + [Parameter] public List Actions { get; set; } = new(); + [Parameter] public EventCallback OnActionTriggered { get; set; } + + private bool _isQuizMode = false; + + private async Task HandleActionClick(string action) + { + if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase)) + { + _isQuizMode = true; + QuizState.RequestQuiz(ContextBlockId); + } + + if (OnActionTriggered.HasDelegate) + { + await OnActionTriggered.InvokeAsync(action); + } + } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor.css new file mode 100644 index 0000000..d65c40c --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor.css @@ -0,0 +1,108 @@ +.ai-bubble-container { + margin: 2rem 0; + display: flex; + justify-content: center; +} + +.ai-bubble { + position: relative; + display: flex; + flex-direction: row; + gap: 1.5rem; + padding: 1.5rem; + background: rgba(18, 18, 18, 0.95); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + max-width: 600px; + color: #fff; +} + +.ai-avatar { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + min-width: 100px; +} + +.avatar-label { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.avatar-label .name { + font-size: 0.8rem; + font-weight: 600; + color: #fff; +} + +.avatar-label .role { + font-size: 0.7rem; + opacity: 0.6; +} + +.neon-pulse { + color: var(--nexus-neon); + filter: drop-shadow(0 0 8px var(--nexus-neon)); + animation: pulse 2s infinite ease-in-out; +} + +@keyframes pulse { + 0% { transform: scale(1); filter: drop-shadow(0 0 8px var(--nexus-neon)); } + 50% { transform: scale(1.05); filter: drop-shadow(0 0 15px var(--nexus-neon)); } + 100% { transform: scale(1); filter: drop-shadow(0 0 8px var(--nexus-neon)); } +} + +.ai-content { + display: flex; + flex-direction: column; + gap: 1rem; + justify-content: center; +} + +.ai-actions { + display: flex; + gap: 1rem; +} + +.action-btn { + padding: 0.5rem 1rem; + border-radius: 20px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: var(--nexus-font-sans); +} + +.action-btn.ghost { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + color: #aaa; +} + +.action-btn.neon-border { + background: rgba(0, 255, 153, 0.1); + border: 1px solid var(--nexus-neon); + color: var(--nexus-neon); +} + +.action-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 255, 153, 0.2); +} + +.bubble-pointer { + position: absolute; + right: -10px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-left: 10px solid rgba(18, 18, 18, 0.95); +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor new file mode 100644 index 0000000..ebbe682 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor @@ -0,0 +1,47 @@ +@using NexusReader.UI.Shared.Services +@inject IFocusModeService FocusMode + + + +@code { + protected override void OnInitialized() + { + FocusMode.OnFocusModeChanged += StateHasChanged; + } + + public void Dispose() + { + FocusMode.OnFocusModeChanged -= StateHasChanged; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor.css new file mode 100644 index 0000000..64ca2e7 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor.css @@ -0,0 +1,48 @@ +.intelligence-toolbar { + width: 50px; + height: 100%; + background: #1a1a1a; + border-right: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 1rem 0; + align-items: center; + z-index: 20; +} + +.toolbar-top, .toolbar-middle, .toolbar-bottom { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.toolbar-item { + background: none; + border: none; + color: #666; + cursor: pointer; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + border-radius: 4px; +} + +.toolbar-item:hover { + color: #fff; + background: rgba(255, 255, 255, 0.05); +} + +.toolbar-item.active { + color: var(--nexus-neon); +} + +.toolbar-item.focus-active { + filter: drop-shadow(0 0 5px var(--nexus-neon)); +} + +.rotate-180 { + transform: rotate(180deg); +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor new file mode 100644 index 0000000..ffbecfe --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor @@ -0,0 +1,114 @@ +@using MediatR +@using NexusReader.Application.Queries.Quiz +@using NexusReader.Application.Commands.Quiz +@using NexusReader.Application.Abstractions.Services +@inject IMediator Mediator +@inject IPlatformService PlatformService + +
+
+ Sprawdzian Wiedzy + +
+ + @if (_isLoading) + { +
Pobieranie pytań...
+ } + else if (_quiz != null) + { +
+ @foreach (var question in _quiz.Questions) + { +
+

@question.Question

+ +
+ @for (int i = 0; i < question.Options.Count; i++) + { + var index = i; + var letter = (char)('A' + i); + + } +
+
+ } + + +
+ } +
+ + +@code { + [Parameter] public string ContextBlockId { get; set; } = string.Empty; + + private bool _isLoading = true; + private QuizDto? _quiz; + + private Dictionary _states = new(); + + protected override async Task OnInitializedAsync() + { + _isLoading = true; + var query = new GetQuizQuestionsQuery(ContextBlockId); + var result = await Mediator.Send(query); + + if (result.IsSuccess) + _quiz = result.Value; + + _isLoading = false; + } + + private async Task SelectOptionAsync(QuizQuestionDto question, int index) + { + if (_states.ContainsKey(question)) return; + + // Haptic feedback + await PlatformService.VibrateAsync(40); + + var cmd = new SubmitAnswerCommand(index, question.CorrectIndex); + var res = await Mediator.Send(cmd); + + _states[question] = (index, res.IsSuccess); + + if (res.IsSuccess) + await PlatformService.VibrateSuccessAsync(); + else + await PlatformService.VibrateErrorAsync(); + + StateHasChanged(); + } + + private bool AllQuestionsAnswered() + { + return _quiz != null && _states.Count == _quiz.Questions.Count; + } + + + private string GetBlockClass(QuizQuestionDto question) + { + if (!_states.TryGetValue(question, out var state)) return ""; + return state.IsCorrect ? "state-correct" : "state-incorrect"; + } + + private string GetOptionClass(QuizQuestionDto question, int index) + { + if (!_states.TryGetValue(question, out var state)) return ""; + + if (state.SelectedIndex == index) + return state.IsCorrect ? "option-correct" : "option-incorrect"; + + if (state.IsCorrect == false && question.CorrectIndex == index) + return "option-revealed-correct"; + + return "option-faded"; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor.css new file mode 100644 index 0000000..5237354 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor.css @@ -0,0 +1,108 @@ +.knowledge-check { + padding: 1.5rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + margin: 1rem; +} + +.quiz-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + font-family: var(--nexus-font-sans); +} + +.header-title { + font-size: 1.1rem; + font-weight: 600; + color: #fff; +} + +.expand-btn { + background: none; + border: none; + color: #666; + font-size: 1.2rem; + cursor: pointer; +} + +.question-text { + font-size: 0.95rem; + color: #ccc; + margin-bottom: 1rem; +} + +.options-list { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.option-item { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.8rem 1rem; + display: flex; + align-items: center; + gap: 1rem; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + width: 100%; +} + +.option-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.option-item.selected { + border-color: var(--nexus-neon); + background: rgba(0, 255, 153, 0.05); +} + +.option-letter { + font-weight: 600; + color: var(--nexus-neon); + min-width: 25px; +} + +.option-text { + font-size: 0.9rem; + color: #fff; +} + +.quiz-footer { + margin-top: 1.5rem; + display: flex; + justify-content: center; +} + +.submit-btn { + padding: 0.6rem 2.5rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 20px; + color: #888; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.submit-btn:not(:disabled) { + background: var(--nexus-neon); + color: #000; + border-color: var(--nexus-neon); +} + +.option-correct { + border-color: #00ff99 !important; + background: rgba(0, 255, 153, 0.1) !important; +} + +.option-incorrect { + border-color: #ff4444 !important; + background: rgba(255, 68, 68, 0.1) !important; +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor new file mode 100644 index 0000000..5726f91 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor @@ -0,0 +1,112 @@ +@using MediatR +@using NexusReader.Application.Queries.Graph +@using Microsoft.JSInterop +@using NexusReader.UI.Shared.Services +@implements IAsyncDisposable +@inject IMediator Mediator +@inject IJSRuntime JS +@inject IFocusModeService FocusMode + +
+ @if (GraphData == null) + { +
+ + Analyzing Chapter Nodes... +
+ } + else + { +
+ + + +
+ } +
+ + +@code { + [Parameter] public EventCallback OnNodeSelected { get; set; } + + private string ContainerId = "d3-graph-container"; + private GraphDataDto? GraphData; + private IJSObjectReference? _module; + private DotNetObjectReference? _dotNetHelper; + + protected override void OnInitialized() + { + FocusMode.OnFocusModeChanged += HandleFocusSimulation; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + var result = await Mediator.Send(new GetKnowledgeGraphQuery()); + if (result.IsSuccess) + { + GraphData = result.Value; + StateHasChanged(); + await InitializeGraphAsync(); + } + } + } + + private async Task InitializeGraphAsync() + { + _module = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/knowledgeGraph.js"); + _dotNetHelper = DotNetObjectReference.Create(this); + await _module.InvokeVoidAsync("mount", ContainerId, GraphData, _dotNetHelper); + } + + private async Task ZoomIn() => await (_module?.InvokeVoidAsync("zoomIn") ?? ValueTask.CompletedTask); + private async Task ZoomOut() => await (_module?.InvokeVoidAsync("zoomOut") ?? ValueTask.CompletedTask); + private async Task ZoomReset() => await (_module?.InvokeVoidAsync("zoomReset") ?? ValueTask.CompletedTask); + + [JSInvokable] + public async Task OnNodeClicked(string nodeId) + { + if (OnNodeSelected.HasDelegate) + { + await OnNodeSelected.InvokeAsync(nodeId); + } + } + + + private async void HandleFocusSimulation() + { + if (_module == null) return; + try + { + if (FocusMode.IsFocusModeActive) + await _module.InvokeVoidAsync("pause"); + else + await _module.InvokeVoidAsync("resume"); + } + catch { } + } + + public async ValueTask DisposeAsync() + { + FocusMode.OnFocusModeChanged -= HandleFocusSimulation; + try + { + if (_module is not null) + { + await _module.InvokeVoidAsync("unmount", ContainerId); + await _module.DisposeAsync(); + } + } + catch (JSDisconnectedException) + { + // Ignored, the circuit is already closed + } + catch (TaskCanceledException) + { + // Ignored, the circuit is already closed + } + + _dotNetHelper?.Dispose(); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css new file mode 100644 index 0000000..d9665c7 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css @@ -0,0 +1,75 @@ +.knowledge-graph-container { + width: 100%; + height: 50vh; + min-height: 400px; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + overflow: hidden; + position: relative; +} + +.graph-controls { + position: absolute; + bottom: 1rem; + right: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 10; +} + +.zoom-btn { + width: 28px; + height: 28px; + background: rgba(18, 18, 18, 0.8); + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: #888; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.zoom-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--nexus-neon); + border-color: var(--nexus-neon); +} + +.zoom-btn.reset { + font-size: 0.8rem; +} + + +.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: 2px !important; + filter: drop-shadow(0 0 12px var(--nexus-neon)); + transition: all 0.3s ease; +} + +.neon-glow { + color: var(--nexus-neon); + filter: drop-shadow(0 0 5px var(--nexus-neon)); +} + diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor new file mode 100644 index 0000000..2bd7f6a --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -0,0 +1,79 @@ +@using MediatR +@using NexusReader.Application.Queries.Reader +@using Microsoft.JSInterop +@using NexusReader.UI.Shared.Services +@implements IDisposable +@inject IMediator Mediator +@inject IJSRuntime JS +@inject IThemeService ThemeService +@inject IFocusModeService FocusMode + +
+ + + @if (ViewModel == null) + { + @StatusMessage + } + else + { +
+ @foreach (var block in ViewModel.Blocks) + { +
+ @if (block is TextSegmentBlock textSegment) + { + @textSegment.Content + } + else if (block is AiActionTriggerBlock aiTrigger) + { + + } +
+ } +
+ } +
+ +@code { + private ReaderPageViewModel? ViewModel; + private string StatusMessage = "Loading chapter..."; + + protected override async Task OnInitializedAsync() + { + ThemeService.OnThemeChanged += StateHasChanged; + + var result = await Mediator.Send(new GetReaderPageQuery()); + if (result.IsSuccess) + { + ViewModel = result.Value; + } + else + { + StatusMessage = "Failed to load chapter content."; + } + } + + private void HandleAiAction(string action) + { + Console.WriteLine($"Action Triggered from Bubble: {action}"); + } + + public async Task ScrollToNodeAsync(string id) + { + try + { + await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});"); + } + catch { } + } + + public void Dispose() + { + ThemeService.OnThemeChanged -= StateHasChanged; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css new file mode 100644 index 0000000..053d648 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css @@ -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; +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor new file mode 100644 index 0000000..e5bd1a7 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor @@ -0,0 +1,21 @@ +
+ +
+ +@code { + [Parameter] public int Progress { get; set; } = 45; +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor.css new file mode 100644 index 0000000..282bf4a --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor.css @@ -0,0 +1,53 @@ +.reader-footer { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 40px; + background: #F9F9F9; + border-top: 1px solid rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + padding: 0 1.5rem; + z-index: 10; +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + font-family: var(--nexus-font-sans); + font-size: 0.75rem; + color: #666; +} + +.progress-container { + flex: 1; + height: 4px; + background: rgba(0, 0, 0, 0.1); + margin: 0 2rem; + border-radius: 2px; + overflow: hidden; + max-width: 400px; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, #00ff99 0%, #00d4ff 100%); + border-radius: 2px; +} + +.page-info, .meta-info { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.label { + opacity: 0.7; +} + +.value { + font-weight: 600; +} diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor b/src/NexusReader.UI.Shared/Layout/MainLayout.razor new file mode 100644 index 0000000..9291f2b --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/MainLayout.razor @@ -0,0 +1,63 @@ +@inherits LayoutComponentBase +@using NexusReader.Application.Abstractions.Services +@using NexusReader.UI.Shared.Services +@using NexusReader.UI.Shared.Components.Molecules +@using NexusReader.UI.Shared.Components.Organisms +@inject IPlatformService PlatformService +@inject IFocusModeService FocusMode + +
+
+
+ @Body +
+ +
+ +
+ +
+
+ + Asystent AI i Interaktywna Mapa + +
+ +
+ + +
+
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ +@code { + private string _platformClass = "platform-desktop"; + + protected override void OnInitialized() + { + FocusMode.OnFocusModeChanged += StateHasChanged; + + var context = PlatformService.GetDeviceContext(); + if (context.IsSuccess) + { + _platformClass = context.Value.DeviceType switch + { + DeviceType.Phone or DeviceType.Tablet => "platform-mobile", + _ => "platform-desktop" + }; + } + } + + public void Dispose() + { + FocusMode.OnFocusModeChanged -= StateHasChanged; + } +} + diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css b/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css new file mode 100644 index 0000000..e5585bd --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css @@ -0,0 +1,93 @@ +.app-container { + display: grid; + grid-template-columns: 1fr 450px; + width: 100vw; + height: 100vh; + overflow: hidden; + background: #121212; + transition: grid-template-columns 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + + +.reader-pane { + background: #F9F9F9; + position: relative; + overflow-y: auto; + display: flex; + flex-direction: column; + z-index: 5; +} + +.intelligence-sidebar { + display: grid; + grid-template-columns: 50px 1fr; + width: 450px; + height: 100%; + background: #121212; + border-left: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +.app-container.focus-mode-active { + grid-template-columns: 1fr 0px; +} + +.app-container.focus-mode-active .intelligence-sidebar { + width: 0; + border-left-width: 0; + opacity: 0; +} + + +.intelligence-content { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.intelligence-header { + height: 50px; + display: flex; + align-items: center; + padding: 0 1.5rem; + gap: 0.8rem; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + font-family: var(--nexus-font-sans); + font-size: 0.9rem; + color: #fff; + flex-shrink: 0; +} + +.intelligence-header .close-btn { + margin-left: auto; + background: none; + border: none; + color: #666; + font-size: 1.5rem; + cursor: pointer; +} + +.intelligence-scroll-area { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +main { + flex: 1; + padding-bottom: 40px; /* footer height */ +} + +/* Platform Specifics */ +.platform-mobile .intelligence-sidebar { + position: fixed; + right: 0; + top: 0; + bottom: 0; + width: 80%; + z-index: 100; +} diff --git a/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor b/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor new file mode 100644 index 0000000..d79e818 --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor @@ -0,0 +1,32 @@ + + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

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

+ +

+ The session has been paused by the server. +

+

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

+ +
+
diff --git a/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor.css b/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..af38c96 --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor.css @@ -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; + } +} diff --git a/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor.js b/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..1609d42 --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/ReconnectModal.razor.js @@ -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(); + } +} diff --git a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj new file mode 100644 index 0000000..d588858 --- /dev/null +++ b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/NexusReader.UI.Shared/Pages/Home.razor b/src/NexusReader.UI.Shared/Pages/Home.razor new file mode 100644 index 0000000..0e08272 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Home.razor @@ -0,0 +1,78 @@ +@page "/" +@using NexusReader.UI.Shared.Services +@implements IAsyncDisposable +@inject IQuizStateService QuizState +@inject IFocusModeService FocusMode +@inject IJSRuntime JS +Nexus E-Reader + +
+ +
+ + +@code { + private ReaderCanvas? readerCanvas; + private string? _activeQuizBlockId; + + private IJSObjectReference? _interopModule; + private IJSObjectReference? _keydownHandler; + private DotNetObjectReference? _dotNetRef; + + protected override async Task OnInitializedAsync() + { + QuizState.OnQuizRequested += HandleQuizRequested; + FocusMode.OnFocusModeChanged += StateHasChanged; + await FocusMode.InitializeAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try { + _interopModule = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/focusInterop.js"); + _dotNetRef = DotNetObjectReference.Create(this); + _keydownHandler = await _interopModule.InvokeAsync("attachKeyboardListener", _dotNetRef); + } catch { } /* ignored dynamically */ + } + } + + [JSInvokable] + public async Task OnFocusKeypressed() + { + await FocusMode.ToggleAsync(); + StateHasChanged(); + } + + private async Task HandleNodeSelected(string nodeId) + { + if (readerCanvas != null) + { + await readerCanvas.ScrollToNodeAsync(nodeId); + } + } + + private void HandleQuizRequested(string blockId) + { + _activeQuizBlockId = blockId; + StateHasChanged(); + } + + public async ValueTask DisposeAsync() + { + QuizState.OnQuizRequested -= HandleQuizRequested; + FocusMode.OnFocusModeChanged -= StateHasChanged; + + if (_interopModule != null && _keydownHandler != null) + { + try { + await _interopModule.InvokeVoidAsync("detachKeyboardListener", _keydownHandler); + await _interopModule.DisposeAsync(); + await _keydownHandler.DisposeAsync(); + } catch { } // Circuit disconnected catch explicitly + } + + _dotNetRef?.Dispose(); + } +} diff --git a/src/NexusReader.UI.Shared/Pages/Home.razor.css b/src/NexusReader.UI.Shared/Pages/Home.razor.css new file mode 100644 index 0000000..cded173 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Home.razor.css @@ -0,0 +1,15 @@ +.home-reader-container { + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 2rem; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.reader-pane::-webkit-scrollbar { + width: 6px; +} +.reader-pane::-webkit-scrollbar-thumb { + background-color: #ddd; + border-radius: 4px; +} diff --git a/src/NexusReader.UI.Shared/Pages/NotFound.razor b/src/NexusReader.UI.Shared/Pages/NotFound.razor new file mode 100644 index 0000000..f74a7a3 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

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

\ No newline at end of file diff --git a/src/NexusReader.UI.Shared/Routes.razor b/src/NexusReader.UI.Shared/Routes.razor new file mode 100644 index 0000000..1709cd1 --- /dev/null +++ b/src/NexusReader.UI.Shared/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/NexusReader.UI.Shared/Services/FocusModeService.cs b/src/NexusReader.UI.Shared/Services/FocusModeService.cs new file mode 100644 index 0000000..744be38 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/FocusModeService.cs @@ -0,0 +1,44 @@ +using Microsoft.JSInterop; + +namespace NexusReader.UI.Shared.Services; + +public sealed class FocusModeService : IFocusModeService +{ + private readonly IJSRuntime _jsRuntime; + public bool IsFocusModeActive { get; private set; } + public event Action? OnFocusModeChanged; + + public FocusModeService(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async Task InitializeAsync() + { + try + { + var value = await _jsRuntime.InvokeAsync("localStorage.getItem", "nexus_focus_mode"); + if (value == "true" && !IsFocusModeActive) + { + IsFocusModeActive = true; + OnFocusModeChanged?.Invoke(); + } + } + catch + { + // Ignored during pre-rendering or unsupported environments + } + } + + public async Task ToggleAsync() + { + IsFocusModeActive = !IsFocusModeActive; + OnFocusModeChanged?.Invoke(); + + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "nexus_focus_mode", IsFocusModeActive ? "true" : "false"); + } + catch { } + } +} diff --git a/src/NexusReader.UI.Shared/Services/IFocusModeService.cs b/src/NexusReader.UI.Shared/Services/IFocusModeService.cs new file mode 100644 index 0000000..2318632 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/IFocusModeService.cs @@ -0,0 +1,9 @@ +namespace NexusReader.UI.Shared.Services; + +public interface IFocusModeService +{ + bool IsFocusModeActive { get; } + event Action? OnFocusModeChanged; + Task InitializeAsync(); + Task ToggleAsync(); +} diff --git a/src/NexusReader.UI.Shared/Services/IQuizStateService.cs b/src/NexusReader.UI.Shared/Services/IQuizStateService.cs new file mode 100644 index 0000000..9dd705b --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/IQuizStateService.cs @@ -0,0 +1,8 @@ +namespace NexusReader.UI.Shared.Services; + +public interface IQuizStateService +{ + string? CurrentQuizBlockId { get; } + event Action? OnQuizRequested; + void RequestQuiz(string blockId); +} diff --git a/src/NexusReader.UI.Shared/Services/IThemeService.cs b/src/NexusReader.UI.Shared/Services/IThemeService.cs new file mode 100644 index 0000000..84a8409 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/IThemeService.cs @@ -0,0 +1,8 @@ +namespace NexusReader.UI.Shared.Services; + +public interface IThemeService +{ + bool IsLightMode { get; } + event Action? OnThemeChanged; + void ToggleTheme(); +} diff --git a/src/NexusReader.UI.Shared/Services/QuizStateService.cs b/src/NexusReader.UI.Shared/Services/QuizStateService.cs new file mode 100644 index 0000000..25f1843 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/QuizStateService.cs @@ -0,0 +1,13 @@ +namespace NexusReader.UI.Shared.Services; + +public sealed class QuizStateService : IQuizStateService +{ + public string? CurrentQuizBlockId { get; private set; } + public event Action? OnQuizRequested; + + public void RequestQuiz(string blockId) + { + CurrentQuizBlockId = blockId; + OnQuizRequested?.Invoke(blockId); + } +} diff --git a/src/NexusReader.UI.Shared/Services/ThemeService.cs b/src/NexusReader.UI.Shared/Services/ThemeService.cs new file mode 100644 index 0000000..6a4e695 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/ThemeService.cs @@ -0,0 +1,13 @@ +namespace NexusReader.UI.Shared.Services; + +public sealed class ThemeService : IThemeService +{ + public bool IsLightMode { get; private set; } = false; + public event Action? OnThemeChanged; + + public void ToggleTheme() + { + IsLightMode = !IsLightMode; + OnThemeChanged?.Invoke(); + } +} diff --git a/src/NexusReader.UI.Shared/_Imports.razor b/src/NexusReader.UI.Shared/_Imports.razor new file mode 100644 index 0000000..ebf5b2c --- /dev/null +++ b/src/NexusReader.UI.Shared/_Imports.razor @@ -0,0 +1,14 @@ +@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.UI.Shared +@using NexusReader.UI.Shared.Layout +@using NexusReader.UI.Shared.Components.Atoms +@using NexusReader.UI.Shared.Components.Molecules +@using NexusReader.UI.Shared.Components.Organisms +@using NexusReader.UI.Shared.Services diff --git a/src/NexusReader.UI.Shared/wwwroot/app.css b/src/NexusReader.UI.Shared/wwwroot/app.css new file mode 100644 index 0000000..442ec39 --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/app.css @@ -0,0 +1,76 @@ +@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: #1a1a1a; + --nexus-text: #ffffff; + --nexus-paper: #F9F9F9; + --nexus-font-sans: 'Inter', sans-serif; + --nexus-font-serif: 'Merriweather', serif; + + /* Safe Area Insets with fallbacks */ + --safe-area-inset-top: env(safe-area-inset-top, 0px); + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-inset-left: env(safe-area-inset-left, 0px); + --safe-area-inset-right: env(safe-area-inset-right, 0px); + + /* Transitions */ + --nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + + +.theme-light { + --nexus-bg: var(--nexus-paper); + --nexus-card: #ffffff; + --nexus-text: #121212; +} + + +body { + background-color: var(--nexus-bg); + color: var(--nexus-text); + font-family: var(--nexus-font-sans); + margin: 0; + padding: 0; + + /* Handle Notches */ + padding-top: var(--safe-area-inset-top); + padding-bottom: var(--safe-area-inset-bottom); + padding-left: var(--safe-area-inset-left); + padding-right: var(--safe-area-inset-right); + + min-height: 100vh; + overflow-x: hidden; +} + +/* Platform Specific Tweaks */ +.platform-mobile .nexus-button { + min-height: var(--touch-target-size); + min-width: var(--touch-target-size); + font-size: 1.1rem; + padding: 12px 24px; +} + +.platform-desktop .nexus-button { + min-height: 36px; + font-size: 0.9rem; + padding: 8px 16px; +} + +/* D3.js Touch Optimization */ +svg { + touch-action: none; +} + +h1:focus { + outline: none; +} + +.blazor-error-boundary { + background: #b32121; + padding: 1rem; + color: white; + margin: 1rem; + border-radius: 8px; +} \ No newline at end of file diff --git a/src/NexusReader.UI.Shared/wwwroot/background.png b/src/NexusReader.UI.Shared/wwwroot/background.png new file mode 100644 index 0000000..e15a3bd Binary files /dev/null and b/src/NexusReader.UI.Shared/wwwroot/background.png differ diff --git a/src/NexusReader.UI.Shared/wwwroot/exampleJsInterop.js b/src/NexusReader.UI.Shared/wwwroot/exampleJsInterop.js new file mode 100644 index 0000000..5294766 --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/exampleJsInterop.js @@ -0,0 +1,6 @@ +// This is a JavaScript module that is loaded on demand. It can export any number of +// functions, and may import other JavaScript modules if required. + +export function showPrompt(message) { + return prompt(message, 'Type anything here'); +} diff --git a/src/NexusReader.UI.Shared/wwwroot/js/focusInterop.js b/src/NexusReader.UI.Shared/wwwroot/js/focusInterop.js new file mode 100644 index 0000000..a33bf2a --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/js/focusInterop.js @@ -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); + } +} diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js new file mode 100644 index 0000000..82c92db --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -0,0 +1,204 @@ +import * as d3 from 'https://esm.sh/d3@7'; + +let simulation; +let zoomBehavior; +let svgElement; + +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 + svgElement = d3.select(container).append("svg") + .attr("viewBox", [0, 0, width, height]) + .attr("width", "100%") + .attr("height", "100%"); + + // Radial gradient for Nebula effect + const defs = svgElement.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 = svgElement.append("g").attr("class", "zoom-containment"); + + // Badge Element (TU JESTEŚ) + const badge = rootGroup.append("g") + .attr("class", "active-badge") + .style("display", "none"); + + badge.append("rect") + .attr("x", -35) + .attr("y", -35) + .attr("width", 70) + .attr("height", 20) + .attr("rx", 10) + .attr("fill", "var(--nexus-neon)"); + + badge.append("text") + .text("TU JESTEŚ") + .attr("text-anchor", "middle") + .attr("y", -21) + .attr("fill", "#000") + .attr("font-weight", "bold") + .attr("font-size", "0.6rem"); + + // Attach Zoom Behavior + zoomBehavior = d3.zoom() + .scaleExtent([0.5, 4]) + .on("zoom", (e) => rootGroup.attr("transform", e.transform)); + + // Apply zoom but disable wheel interaction + svgElement.call(zoomBehavior) + .on("wheel.zoom", null); + + + // Subtle Link Distance & Charge + simulation = d3.forceSimulation(data.nodes) + .force("link", d3.forceLink(data.links).id(d => d.id).distance(100)) + .force("charge", d3.forceManyBody().strength(-300)) + .force("center", d3.forceCenter(width / 2, height / 2)) + .force("collide", d3.forceCollide().radius(40)); + + // Links + const link = rootGroup.append("g") + .selectAll("path") + .data(data.links) + .join("path") + .attr("stroke", "rgba(255,255,255,0.1)") + .attr("fill", "none") + .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.selectAll(".node-pill").classed("nexus-node-active", false); + d3.select(e.currentTarget).select(".node-pill").classed("nexus-node-active", true); + + // Show badge + badge.style("display", "block").datum(d); + + dotNetHelper.invokeMethodAsync('OnNodeClicked', d.id); + }) + .call(drag(simulation)); + + // Outer glow for nodes + node.append("circle") + .attr("r", 30) + .attr("fill", "url(#nebulaGlow)") + .attr("opacity", d => d.id === 'root' ? 0.6 : 0.2); + + // Pill shape + node.append("rect") + .attr("class", "node-pill") + .attr("x", d => -(d.label.length * 4 + 10)) + .attr("y", -12) + .attr("width", d => d.label.length * 8 + 20) + .attr("height", 24) + .attr("rx", 12) + .attr("fill", "rgba(30, 30, 30, 0.8)") + .attr("stroke", "rgba(255, 255, 255, 0.1)") + .attr("stroke-width", 1); + + // Labels + node.append("text") + .text(d => d.label) + .attr("text-anchor", "middle") + .attr("y", 4) + .attr("fill", "#ccc") + .attr("font-family", "var(--nexus-font-sans)") + .attr("font-size", "0.8rem"); + + simulation.on("tick", () => { + link.attr("d", d => { + const dx = d.target.x - d.source.x; + const dy = d.target.y - d.source.y; + const dr = Math.sqrt(dx * dx + dy * dy); + return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`; + }); + + node.attr("transform", d => `translate(${d.x},${d.y})`); + + const activeData = badge.datum(); + if (activeData) { + badge.attr("transform", `translate(${activeData.x},${activeData.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 zoomIn() { + if (svgElement && zoomBehavior) { + svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 1.3); + } +} + +export function zoomOut() { + if (svgElement && zoomBehavior) { + svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 0.7); + } +} + +export function zoomReset() { + if (svgElement && zoomBehavior) { + svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity); + } +} + diff --git a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj new file mode 100644 index 0000000..73c76f8 --- /dev/null +++ b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + Default + true + + + + + + + + + + + + + + diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs new file mode 100644 index 0000000..62e7af7 --- /dev/null +++ b/src/NexusReader.Web.Client/Program.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Web.Client.Services; +using NexusReader.UI.Shared.Services; +using NexusReader.Application; +using NexusReader.Infrastructure; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); + +await builder.Build().RunAsync(); diff --git a/src/NexusReader.Web.Client/Services/WebPlatformService.cs b/src/NexusReader.Web.Client/Services/WebPlatformService.cs new file mode 100644 index 0000000..658209f --- /dev/null +++ b/src/NexusReader.Web.Client/Services/WebPlatformService.cs @@ -0,0 +1,41 @@ +using FluentResults; +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 VibrateSuccessAsync() => await VibrateAsync(100); + public async Task VibrateErrorAsync() => await VibrateAsync(300); + + public async Task VibrateAsync(int milliseconds) + { + try + { + await _jsRuntime.InvokeVoidAsync("navigator.vibrate", milliseconds); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result GetDeviceContext() + { + return Result.Ok(new DeviceContext( + "Browser", + "Web", + DeviceType.Desktop, // Default for web, or could detect via JS + DisplayOrientation.Unknown + )); + } +} diff --git a/src/NexusReader.Web.Client/wwwroot/js/focusInterop.js b/src/NexusReader.Web.Client/wwwroot/js/focusInterop.js new file mode 100644 index 0000000..a33bf2a --- /dev/null +++ b/src/NexusReader.Web.Client/wwwroot/js/focusInterop.js @@ -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); + } +} diff --git a/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js b/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js new file mode 100644 index 0000000..13be344 --- /dev/null +++ b/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js @@ -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(); + } +} diff --git a/src/NexusReader.Web.New/Components/App.razor b/src/NexusReader.Web.New/Components/App.razor new file mode 100644 index 0000000..faf4c1e --- /dev/null +++ b/src/NexusReader.Web.New/Components/App.razor @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NexusReader.Web.New/Components/Error.razor b/src/NexusReader.Web.New/Components/Error.razor new file mode 100644 index 0000000..37bac5f --- /dev/null +++ b/src/NexusReader.Web.New/Components/Error.razor @@ -0,0 +1,19 @@ +@code { + [Parameter] + public Exception? Exception { get; set; } + + protected override void OnInitialized() + { + // You could log the exception here using a logging service. + } +} + +

An unexpected error occurred.

+

Please try reloading the page. If the problem persists, contact support.

+@if (Exception != null) +{ +
+ Technical details (click to expand) +
@Exception.ToString()
+
+} diff --git a/src/NexusReader.Web.New/Components/Pages/Error.razor b/src/NexusReader.Web.New/Components/Pages/Error.razor new file mode 100644 index 0000000..7a84043 --- /dev/null +++ b/src/NexusReader.Web.New/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

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

+

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

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/NexusReader.Web.New/Components/_Imports.razor b/src/NexusReader.Web.New/Components/_Imports.razor new file mode 100644 index 0000000..c3c610d --- /dev/null +++ b/src/NexusReader.Web.New/Components/_Imports.razor @@ -0,0 +1,14 @@ +@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.UI.Shared +@using NexusReader.UI.Shared.Layout +@using NexusReader.UI.Shared.Components.Atoms +@using NexusReader.UI.Shared.Components.Molecules +@using NexusReader.UI.Shared.Components.Organisms diff --git a/src/NexusReader.Web.New/NexusReader.Web.csproj b/src/NexusReader.Web.New/NexusReader.Web.csproj new file mode 100644 index 0000000..5da9a26 --- /dev/null +++ b/src/NexusReader.Web.New/NexusReader.Web.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs new file mode 100644 index 0000000..5355cbc --- /dev/null +++ b/src/NexusReader.Web.New/Program.cs @@ -0,0 +1,58 @@ +using NexusReader.Web.Components; +using NexusReader.Application; +using NexusReader.Infrastructure; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Web.Client.Services; +using NexusReader.UI.Shared.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents(); + +// Enable detailed circuit errors for Server‑Side Blazor components +builder.Services.AddServerSideBlazor() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(NexusReader.UI.Shared._Imports).Assembly); + + +app.Run(); diff --git a/src/NexusReader.Web.New/Properties/launchSettings.json b/src/NexusReader.Web.New/Properties/launchSettings.json new file mode 100644 index 0000000..384b224 --- /dev/null +++ b/src/NexusReader.Web.New/Properties/launchSettings.json @@ -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" + } + } + } + } diff --git a/src/NexusReader.Web.New/appsettings.Development.json b/src/NexusReader.Web.New/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/NexusReader.Web.New/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/NexusReader.Web.New/appsettings.json b/src/NexusReader.Web.New/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/src/NexusReader.Web.New/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/NexusReader.Web.New/wwwroot/app.css b/src/NexusReader.Web.New/wwwroot/app.css new file mode 100644 index 0000000..b32080d --- /dev/null +++ b/src/NexusReader.Web.New/wwwroot/app.css @@ -0,0 +1,63 @@ +@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; +} + +.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; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file