fix(di): resolve client-side WASM dependency injection validation errors on login

This commit is contained in:
2026-06-07 13:56:09 +02:00
parent dab698ee72
commit 1eacf7ed93
11 changed files with 170 additions and 48 deletions
@@ -15,7 +15,7 @@
@if (_isLoading) @if (_isLoading)
{ {
<div class="loading-state" role="status" aria-label="Ładowanie rekomendacji"> <div @key='"loading"' class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
<div class="spinner-ring"> <div class="spinner-ring">
<div class="spinner-track"></div> <div class="spinner-track"></div>
<div class="spinner-head"></div> <div class="spinner-head"></div>
@@ -25,24 +25,24 @@
} }
else if (_hasError) else if (_hasError)
{ {
<div class="empty-state"> <div @key='"error"' class="empty-state">
<NexusIcon Name="alert-circle" Size="32" /> <NexusIcon Name="alert-circle" Size="32" />
<p>Nie udało się załadować rekomendacji.</p> <p>Nie udało się załadować rekomendacji.</p>
</div> </div>
} }
else if (_recommendations is null || _recommendations.Count == 0) else if (_recommendations is null || _recommendations.Count == 0)
{ {
<div class="empty-state"> <div @key='"empty"' class="empty-state">
<NexusIcon Name="book-open" Size="32" /> <NexusIcon Name="book-open" Size="32" />
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p> <p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
</div> </div>
} }
else else
{ {
<ul class="recommendations-list" role="list"> <ul @key='"list"' class="recommendations-list" role="list">
@foreach (var rec in _recommendations) @foreach (var rec in _recommendations)
{ {
<li class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")" <li @key="rec.TargetBookId" class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
role="listitem"> role="listitem">
<div class="rec-content"> <div class="rec-content">
<div class="rec-meta"> <div class="rec-meta">
@@ -5,7 +5,7 @@
<section class="current-reading-card glass-panel"> <section class="current-reading-card glass-panel">
@if (Book != null) @if (Book != null)
{ {
<div class="card-layout"> <div @key='"current-reading-book"' class="card-layout">
<div class="book-cover"> <div class="book-cover">
<img src="@(Book.CoverUrl ?? "https://via.placeholder.com/120x180?text=No+Cover")" alt="@Book.Title" /> <img src="@(Book.CoverUrl ?? "https://via.placeholder.com/120x180?text=No+Cover")" alt="@Book.Title" />
</div> </div>
@@ -51,7 +51,7 @@
} }
else else
{ {
<div class="empty-state"> <div @key='"current-reading-empty"' class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<NexusIcon Name="book-open" Size="48" /> <NexusIcon Name="book-open" Size="48" />
</div> </div>
@@ -6,13 +6,13 @@
@if (!_isFullyLoaded) @if (!_isFullyLoaded)
{ {
<div class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000; color: #ffffff;"> <div @key='"preloader"' class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000; color: #ffffff;">
<div class="preloader-spinner"></div> <div class="preloader-spinner"></div>
<div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div> <div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div>
</div> </div>
} }
<div class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")"> <div @key='"hub-container"' class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<!-- Mobile Sticky Top-bar --> <!-- Mobile Sticky Top-bar -->
@@ -33,7 +33,7 @@
<span>lub</span> <span>lub</span>
</div> </div>
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form"> <EditForm FormName="login-form" Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="field-group"> <div class="field-group">
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none"> <form @formname="hidden-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
<input type="hidden" name="email" value="@_loginModel.Email" /> <input type="hidden" name="email" value="@_loginModel.Email" />
<input type="hidden" name="password" value="@_loginModel.Password" /> <input type="hidden" name="password" value="@_loginModel.Password" />
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" /> <input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
@@ -117,7 +117,10 @@
[SupplyParameterFromQuery(Name = "returnUrl")] [SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; } public string? ReturnUrl { get; set; }
private LoginModel _loginModel = new(); #pragma warning disable BL0008
[SupplyParameterFromForm(FormName = "login-form")]
private LoginModel _loginModel { get; set; } = new();
#pragma warning restore BL0008
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
private bool _showPassword; private bool _showPassword;
@@ -21,7 +21,7 @@
<p class="auth-subtitle">Utwórz nowe konto</p> <p class="auth-subtitle">Utwórz nowe konto</p>
</div> </div>
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form"> <EditForm FormName="register-form" Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="field-group"> <div class="field-group">
@@ -71,14 +71,17 @@
</div> </div>
</div> </div>
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none"> <form @formname="hidden-register-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
<input type="hidden" name="email" value="@_registerModel.Email" /> <input type="hidden" name="email" value="@_registerModel.Email" />
<input type="hidden" name="password" value="@_registerModel.Password" /> <input type="hidden" name="password" value="@_registerModel.Password" />
<input type="hidden" name="rememberMe" value="false" /> <input type="hidden" name="rememberMe" value="false" />
</form> </form>
@code { @code {
private RegisterModel _registerModel = new(); #pragma warning disable BL0008
[SupplyParameterFromForm(FormName = "register-form")]
private RegisterModel _registerModel { get; set; } = new();
#pragma warning restore BL0008
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
@@ -61,24 +61,28 @@
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any()) @if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
{ {
<div @key='"satellite-concepts-container"' style="display: contents;">
@for (int i = 0; i < _profile.MappedConcepts.Count; i++) @for (int i = 0; i < _profile.MappedConcepts.Count; i++)
{ {
var concept = _profile.MappedConcepts[i]; var concept = _profile.MappedConcepts[i];
var angle = i * (360.0 / _profile.MappedConcepts.Count); var angle = i * (360.0 / _profile.MappedConcepts.Count);
var dist = 65; var dist = 65;
<div class="graph-node satellite" <div @key="concept.Id" class="graph-node satellite"
style="--angle: @(angle)deg; --dist: @(dist)px;" style="--angle: @(angle)deg; --dist: @(dist)px;"
title="[@concept.Type] @concept.Content" title="[@concept.Type] @concept.Content"
@onmouseover="() => SetHoveredConcept(concept)" @onmouseover="() => SetHoveredConcept(concept)"
@onmouseout="ClearHoveredConcept"> @onmouseout="ClearHoveredConcept">
</div> </div>
} }
</div>
} }
else else
{ {
<div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div> <div @key='"satellite-placeholders-container"' style="display: contents;">
<div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div> <div @key='"satellite-placeholder-0"' class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
<div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div> <div @key='"satellite-placeholder-1"' class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
<div @key='"satellite-placeholder-2"' class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
</div>
} }
<div class="active-node-label"> <div class="active-node-label">
@@ -111,10 +115,10 @@
<div class="quiz-preview"> <div class="quiz-preview">
@if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any()) @if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any())
{ {
<div class="quiz-history-list"> <div @key='"quiz-history-list"' class="quiz-history-list">
@foreach (var quiz in _profile.RecentQuizzes) @foreach (var quiz in _profile.RecentQuizzes)
{ {
<div class="quiz-history-item"> <div @key="quiz.Id" class="quiz-history-item">
<div class="quiz-item-header"> <div class="quiz-item-header">
<span class="quiz-topic">@quiz.Topic</span> <span class="quiz-topic">@quiz.Topic</span>
<span class="quiz-score badge @(quiz.Percentage >= 80 ? "badge-success" : quiz.Percentage >= 50 ? "badge-warning" : "badge-danger")"> <span class="quiz-score badge @(quiz.Percentage >= 80 ? "badge-success" : quiz.Percentage >= 50 ? "badge-warning" : "badge-danger")">
@@ -130,7 +134,7 @@
} }
else else
{ {
<div class="empty-quiz-state"> <div @key='"empty-quiz-state"' class="empty-quiz-state">
<p class="question">Brak rozwiązanych quizów</p> <p class="question">Brak rozwiązanych quizów</p>
<p class="sub-text">Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.</p> <p class="sub-text">Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.</p>
</div> </div>
+16 -15
View File
@@ -66,24 +66,25 @@
/* Global Semantic Theme Mapping */ /* Global Semantic Theme Mapping */
--nexus-primary: var(--nexus-neon); :root {
--nexus-primary-glow: var(--nexus-neon-glow); --nexus-primary: var(--nexus-neon);
--nexus-primary-hover: #00e688; --nexus-primary-glow: var(--nexus-neon-glow);
--nexus-primary-hover: #00e688;
/* Standard Layout Tokens */ /* Standard Layout Tokens */
--radius-sm: 8px; --radius-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
--radius-lg: 16px; --radius-lg: 16px;
--radius-xl: 20px; --radius-xl: 20px;
/* Safe Area Insets with fallbacks */ /* Safe Area Insets with fallbacks */
--safe-area-inset-top: env(safe-area-inset-top, 0px); --safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px); --safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px); --safe-area-inset-right: env(safe-area-inset-right, 0px);
/* Transitions */ /* Transitions */
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); --nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Global Glassmorphism with Fallback */ /* Global Glassmorphism with Fallback */
+38
View File
@@ -62,6 +62,10 @@ builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepos
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository()); builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster()); builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor()); builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
builder.Services.AddSingleton<IUserLibraryStore>(new ThrowingUserLibraryStore());
builder.Services.AddSingleton<IVectorSearchStore>(new ThrowingVectorSearchStore());
builder.Services.Configure<NexusReader.Application.Common.RagMonetizationOptions>(builder.Configuration.GetSection(NexusReader.Application.Common.RagMonetizationOptions.SectionName));
builder.Services.AddSingleton<IChatClient>(new ThrowingChatClient());
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddScoped<IEpubReader, WasmEpubReader>(); builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
@@ -135,3 +139,37 @@ public class ThrowingEpubExtractor : IEpubExtractor
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client."); => throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
} }
public class ThrowingUserLibraryStore : IUserLibraryStore
{
public Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
public Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
}
public class ThrowingVectorSearchStore : IVectorSearchStore
{
public Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
public Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
public Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
}
public class ThrowingChatClient : IChatClient
{
public void Dispose() { }
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
public object? GetService(Type serviceType, object? serviceKey = null) => null;
}
+2
View File
@@ -53,6 +53,8 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>(); builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
builder.Services.AddScoped<IUserPreferenceStore, NexusReader.Web.Services.ServerUserPreferenceStore>(); builder.Services.AddScoped<IUserPreferenceStore, NexusReader.Web.Services.ServerUserPreferenceStore>();
builder.Services.AddScoped<IThemeService, NexusReader.Web.Services.ServerThemeService>();
builder.Services.AddScoped<IRecommendationService, NexusReader.Web.Services.ServerRecommendationService>();
// Feature settings (avoiding direct raw IConfiguration injection in client pages) // Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings(); var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings); builder.Services.AddSingleton(featureSettings);
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.AspNetCore.Http;
using NexusReader.Application.Queries.Recommendations;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Services;
/// <summary>
/// Server-side implementation of <see cref="IRecommendationService"/> that executes
/// the MediatR query directly inside the Web Server's request context.
/// </summary>
public sealed class ServerRecommendationService : IRecommendationService
{
private readonly IMediator _mediator;
private readonly IHttpContextAccessor _httpContextAccessor;
public ServerRecommendationService(IMediator mediator, IHttpContextAccessor httpContextAccessor)
{
_mediator = mediator;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default)
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User == null)
{
return new List<RecommendationDto>();
}
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return new List<RecommendationDto>();
}
var result = await _mediator.Send(new GetContextualRecommendationsQuery(userId), cancellationToken);
if (result.IsSuccess && result.Value != null)
{
return result.Value.Recommendations;
}
return new List<RecommendationDto>();
}
}
@@ -0,0 +1,21 @@
using NexusReader.Domain.Enums;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Services;
public sealed class ServerThemeService : IThemeService
{
public ThemeMode Mode => ThemeMode.System;
public bool IsLightMode => false;
// Explicit event implementation to avoid CS0067 warning about unused events on the server
public event Action<ThemeMode>? OnThemeChanged
{
add { }
remove { }
}
public Task InitializeAsync() => Task.CompletedTask;
public Task SetThemeAsync(ThemeMode mode) => Task.CompletedTask;
public Task ToggleTheme() => Task.CompletedTask;
}