feat: implement cross-device reading progress synchronization using SignalR and remove legacy quiz generation services.

This commit is contained in:
2026-05-02 19:55:07 +02:00
parent e5611758f1
commit 94ecc7a404
22 changed files with 332 additions and 69 deletions
+4
View File
@@ -24,6 +24,10 @@ description: Standards for cross-platform compatibility (Web & MAUI Hybrid)
- Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop). - 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). - Adapt UI layout dynamically based on the context (e.g., sidebars on Tablet/Desktop, bottom navigation on Phone).
- **Real-time & Events (SignalR / UI):**
- **Debouncing**: Implement trailing-edge debouncing using `CancellationTokenSource` and `Task.Delay` for high-frequency UI events (like scrolling). Do not just drop events inside a time window, as the final state might be lost.
- **Dependency Isolation**: Blazor WebAssembly (`Web.Client`) cannot reference projects that require `Microsoft.AspNetCore.App` (like SignalR Hubs). Keep SignalR abstractions in `UI.Shared` and the Hub implementation strictly on the server (`Infrastructure` or `Web.New`).
- **Dependency Injection:** - **Dependency Injection:**
- Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web. - Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web.
- Components in `NexusReader.UI.Shared` must only depend on the interfaces. - Components in `NexusReader.UI.Shared` must only depend on the interfaces.
+5 -1
View File
@@ -16,6 +16,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
- **Queries**: Read-only operations, return `Task<Result<T>>`. - **Queries**: Read-only operations, return `Task<Result<T>>`.
- **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`. - **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`.
- **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`). - **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`).
- **Client-Server Boundaries**: DO NOT execute MediatR handlers directly from WASM/MAUI clients if the handler relies on server-only infrastructure (e.g., `AppDbContext`, `IHubContext`). Instead, the client must trigger an API or SignalR endpoint, and the server dispatches the MediatR command.
- **Functional Error Handling:** - **Functional Error Handling:**
- Mandatory use of `FluentResults`. - Mandatory use of `FluentResults`.
@@ -35,4 +36,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
- **Cross-Platform Strategy:** - **Cross-Platform Strategy:**
- Maximize code sharing in `NexusReader.UI.Shared`. - Maximize code sharing in `NexusReader.UI.Shared`.
- Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects. - Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects.
- **Code Validation (CRITICAL):**
- **Mandatory Build Verification**: After any code change, the agent MUST run `dotnet build` on the solution. The agent must verify that the build completes with `Exit code: 0` and without errors before concluding the task or requesting user feedback.
+30
View File
@@ -0,0 +1,30 @@
---
name: nexus-code-review
description: Code Review Checklist and Standards for NexusReader SaaS
---
# NexusReader Code Review Standards
When conducting or receiving a code review for NexusReader, ensure the implementation adheres to the following critical architectural and performance standards:
## 1. Architectural Boundaries (CQRS & Blazor Hybrid)
- [ ] **Client vs. Server Execution**: MediatR handlers that depend on server-side infrastructure (`AppDbContext`, `IHubContext`, secrets) MUST NOT be executed directly from client environments (WASM/MAUI).
- [ ] **Dependency Leakage**: Ensure `NexusReader.Web.Client` (WASM) does not reference `NexusReader.Infrastructure` if the infrastructure requires `Microsoft.AspNetCore.App` framework references.
- [ ] **SignalR Bridges**: Client-initiated state changes should be sent via SignalR `SendAsync` to a server Hub, which then dispatches the internal `MediatR` command.
## 2. Event Handling & Debouncing
- [ ] **High-Frequency UI Events**: UI actions like scrolling, resizing, or typing must be debounced.
- [ ] **Trailing-Edge Debounce**: Use a `CancellationTokenSource` and `Task.Delay` to ensure the *last* event in a rapid sequence is executed. Do not use simple time-window drops, as they result in lost final states.
- [ ] **Async Void**: Ensure UI event handlers do not use `async void` unless they are top-level framework event bindings, and even then, they must catch all exceptions.
## 3. SignalR & Real-Time Contexts
- [ ] **Authentication Context**: Do not rely on `IHttpContextAccessor` inside MediatR handlers triggered by SignalR Hubs. Use `Context.UserIdentifier` directly from the Hub and pass it as a command parameter.
- [ ] **Connection State**: Always check `HubConnection.State == HubConnectionState.Connected` before attempting to send messages from the client.
- [ ] **Targeted Broadcasting**: Use SignalR `Groups` (e.g., `$"User_{userId}"`) to broadcast updates only to the devices owned by the relevant user.
## 4. Performance & Scalability
- [ ] **Database Write Contention**: High-frequency telemetry (like reading progress) should ideally be batched or cached in-memory before writing to SQL, unless real-time persistence is strictly required.
- [ ] **Memory Leaks**: Ensure all components and services that subscribe to events (e.g., `OnProgressReceived`, JS Observers) implement `IDisposable` or `IAsyncDisposable` and properly unsubscribe.
## 5. Standard Nexus Guidelines
- [ ] **Result Pattern**: Ensure all application logic returns `Result` or `Result<T>` via FluentResults. No exceptions for control flow.
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
@@ -1,9 +0,0 @@
using FluentResults;
using NexusReader.Application.Queries.Quiz;
namespace NexusReader.Application.Abstractions.Services;
public interface IAiGenerateQuizService
{
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,6 @@
using FluentResults;
using MediatR;
namespace NexusReader.Application.Commands.Sync;
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
@@ -1,5 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Quiz;
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
@@ -1,20 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Queries.Quiz;
internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler<GetQuizQuestionsQuery, QuizDto>
{
private readonly IAiGenerateQuizService _aiService;
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
{
_aiService = aiService;
}
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
{
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
}
}
@@ -36,4 +36,14 @@ public class NexusUser : IdentityUser
/// Collection of quiz results completed by the user. /// Collection of quiz results completed by the user.
/// </summary> /// </summary>
public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>(); public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>();
/// <summary>
/// ID of the last page read by the user.
/// </summary>
public string? LastReadPageId { get; set; }
/// <summary>
/// Timestamp of the last reading progress update.
/// </summary>
public DateTime? LastReadAt { get; set; }
} }
@@ -65,7 +65,6 @@ public static class DependencyInjection
})); }));
services.AddScoped<IKnowledgeService, KnowledgeService>(); services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
services.AddTransient<IEpubService, EpubService>(); services.AddTransient<IEpubService, EpubService>();
services.AddAuthorizationCore(options => services.AddAuthorizationCore(options =>
@@ -75,6 +74,11 @@ public static class DependencyInjection
services.AddScoped<IAuthorizationHandler, ProUserHandler>(); services.AddScoped<IAuthorizationHandler, ProUserHandler>();
services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
return services; return services;
} }
} }
@@ -0,0 +1,46 @@
using FluentResults;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Commands.Sync;
using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Persistence;
using NexusReader.Infrastructure.RealTime;
namespace NexusReader.Infrastructure.Handlers;
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
{
private readonly AppDbContext _context;
private readonly IHubContext<SyncHub> _hubContext;
public UpdateReadingProgressCommandHandler(
AppDbContext context,
IHubContext<SyncHub> hubContext)
{
_context = context;
_hubContext = hubContext;
}
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
var now = DateTime.UtcNow;
user.LastReadPageId = request.PageId;
user.LastReadAt = now;
await _context.SaveChangesAsync(cancellationToken);
// Broadcast to other devices
await _hubContext.Clients
.Group($"User_{request.UserId}")
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
return Result.Ok();
}
}
@@ -1,9 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" /> <ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" /> <PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
@@ -16,6 +16,12 @@ public class AppDbContext : IdentityDbContext<NexusUser>
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<NexusUser>(entity =>
{
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
entity.Property(u => u.LastReadAt).IsRequired(false);
});
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SemanticKnowledgeCache>(entity => modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Authorization;
using NexusReader.Application.Commands.Sync;
namespace NexusReader.Infrastructure.RealTime;
[Authorize]
public class SyncHub : Hub
{
private readonly IMediator _mediator;
public SyncHub(IMediator mediator)
{
_mediator = mediator;
}
public async Task UpdateProgress(string pageId)
{
var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId))
{
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId));
}
}
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"User_{userId}");
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId))
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"User_{userId}");
}
await base.OnDisconnectedAsync(exception);
}
}
@@ -1,23 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Quiz;
namespace NexusReader.Infrastructure.Services;
public sealed class FakeAiGenerateQuizService : IAiGenerateQuizService
{
public async Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default)
{
// 2000ms delay to highlight Skeleton loader visually
await Task.Delay(2000, cancellationToken);
var fakeQuiz = new List<QuizQuestionDto>
{
new("Co było głównym centrum włoskiego Renesansu?", new List<string> { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2),
new("Kto stanowił wpływowy ród mecenasów sztuki?", new List<string> { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1),
new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List<string> { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2)
};
return Result.Ok(new QuizDto(fakeQuiz));
}
}
+10
View File
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Mobile.Services; using NexusReader.Infrastructure.Mobile.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
using NexusReader.Application;
using MediatR;
namespace NexusReader.Maui; namespace NexusReader.Maui;
@@ -39,6 +41,14 @@ public static class MauiProgram
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddApplication();
return builder.Build(); return builder.Build();
} }
@@ -10,6 +10,7 @@
@inject IReaderNavigationService NavigationService @inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator @inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@inject ISyncService SyncService
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")"> <div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@if (ViewModel == null) @if (ViewModel == null)
@@ -77,6 +78,11 @@
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender)
{
await SyncService.InitializeAsync();
}
if (ViewModel != null && !_isJsInitialized) if (ViewModel != null && !_isJsInitialized)
{ {
_isJsInitialized = true; _isJsInitialized = true;
@@ -109,6 +115,23 @@
public void HandleBlockReached(string blockId, string content) public void HandleBlockReached(string blockId, string content)
{ {
Coordinator.OnBlockReached(blockId, content); Coordinator.OnBlockReached(blockId, content);
// Debounce sync update (simple version: every 5 seconds or on a timer)
_ = SyncService.UpdateProgressAsync(blockId);
}
private void HandleSyncProgressReceived(string blockId, DateTime timestamp)
{
// For now, let's just scroll to the node if it's in the current view,
// or just log it. Usually, we should prompt the user.
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
// Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
// but we can assume incoming syncs are from other active devices)
_ = InvokeAsync(async () => {
await ScrollToNodeAsync(blockId);
StateHasChanged();
});
} }
[JSInvokable] [JSInvokable]
@@ -196,5 +219,6 @@
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested; InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested; InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected; InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
} }
} }
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
<PackageReference Include="MediatR" Version="12.1.1" /> <PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,11 @@
using FluentResults;
namespace NexusReader.UI.Shared.Services;
public interface ISyncService
{
Task<Result> InitializeAsync();
Task<Result> UpdateProgressAsync(string pageId);
event Action<string, DateTime> OnProgressReceived;
Task DisposeAsync();
}
@@ -0,0 +1,112 @@
using FluentResults;
using Microsoft.AspNetCore.SignalR.Client;
using NexusReader.Application.Abstractions.Services;
using System.Net.Http;
namespace NexusReader.UI.Shared.Services;
public class SyncService : ISyncService, IAsyncDisposable
{
private readonly HttpClient _httpClient;
private readonly INativeStorageService _storageService;
private readonly IPlatformService _platformService;
private HubConnection? _hubConnection;
private bool _isInitialized;
private CancellationTokenSource? _debounceCts;
public event Action<string, DateTime>? OnProgressReceived;
public SyncService(
HttpClient httpClient,
INativeStorageService storageService,
IPlatformService platformService)
{
_httpClient = httpClient;
_storageService = storageService;
_platformService = platformService;
}
public async Task<Result> InitializeAsync()
{
if (_isInitialized) return Result.Ok();
var tokenResult = await _storageService.GetSecureString("nexus_auth_token");
if (tokenResult.IsFailed) return Result.Fail("Not authenticated");
var baseUrl = _httpClient.BaseAddress?.ToString() ?? "http://localhost:5000/";
var hubUrl = new Uri(new Uri(baseUrl), "synchub").ToString();
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(tokenResult.Value);
})
.WithAutomaticReconnect()
.Build();
_hubConnection.On<string, DateTime>("ProgressUpdated", (pageId, timestamp) =>
{
OnProgressReceived?.Invoke(pageId, timestamp);
});
try
{
await _hubConnection.StartAsync();
_isInitialized = true;
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
private string? _lastSentPageId;
public async Task<Result> UpdateProgressAsync(string pageId)
{
if (pageId == _lastSentPageId) return Result.Ok();
// Proper trailing-edge debounce
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var token = _debounceCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(2000, token);
if (!_isInitialized) await InitializeAsync();
if (_hubConnection?.State == HubConnectionState.Connected)
{
await _hubConnection.SendAsync("UpdateProgress", pageId, token);
_lastSentPageId = pageId;
}
}
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
catch (Exception ex)
{
Console.WriteLine($"[SyncService] Error sending progress: {ex.Message}");
}
}, token);
return Result.Ok();
}
public async Task DisposeAsync()
{
_debounceCts?.Cancel();
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
await DisposeAsync();
}
}
@@ -16,7 +16,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" /> <ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" /> <ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
</ItemGroup> </ItemGroup>
+2 -3
View File
@@ -4,8 +4,7 @@ using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services; using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
using NexusReader.Application; using NexusReader.Application;
using NexusReader.Infrastructure;
using NexusReader.Infrastructure.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -19,6 +18,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>(); builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
// Identity & Auth Services // Identity & Auth Services
builder.Services.AddOptions(); builder.Services.AddOptions();
@@ -34,6 +34,5 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddScoped<IEpubService, WasmEpubService>(); builder.Services.AddScoped<IEpubService, WasmEpubService>();
builder.Services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
+4
View File
@@ -31,6 +31,8 @@ builder.Services.AddServerSideBlazor()
{ {
options.DetailedErrors = true; options.DetailedErrors = true;
}); });
builder.Services.AddSignalR();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>(); builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
@@ -41,6 +43,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>(); builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddHttpClient("NexusAPI", client => builder.Services.AddHttpClient("NexusAPI", client =>
{ {
@@ -181,6 +184,7 @@ app.UseAntiforgery();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
// API endpoint for WASM client to fetch EPUB content // API endpoint for WASM client to fetch EPUB content
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) => app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>