feat(ui): Hub Navigation, Profile Dashboard and Auth Stability Fixes (#31)

This PR implements the Hub Navigation system and the Profile Dashboard, while resolving critical session synchronization issues.

### Key Changes
- **Hub Navigation**: Introduced `MainHubLayout` with a premium glassmorphism sidebar, providing access to Dashboard, Library, Concepts Map, and Profile.
- **Profile Dashboard**: Implemented a high-fidelity Profile page (#27) with learning metrics, AI token usage tracking, and system rank visualization.
- **Stability Fixes**:
    - Resolved an infinite network loop on the `/profile` page by implementing request deduplication and in-memory caching in `IdentityService`.
    - Added environment-aware guards to prevent illegal JavaScript interop calls during server-side prerendering.
    - Implemented automatic session invalidation on `401 Unauthorized` responses to handle stale authentication states gracefully.
- **Reader Integration**: Added a "Return to Dashboard" option in the reader toolbar (#26).

Closes #26
Closes #27

Reviewed-on: #31
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
This commit was merged in pull request #31.
This commit is contained in:
2026-05-10 17:36:35 +00:00
committed by Marek Jaisński
parent 34794db209
commit 2e23a032d3
56 changed files with 4292 additions and 481 deletions
@@ -5,5 +5,5 @@ namespace NexusReader.Application.Abstractions.Services;
public interface IEpubService
{
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex);
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null);
}
@@ -3,4 +3,11 @@ using MediatR;
namespace NexusReader.Application.Commands.Sync;
public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest<Result>;
public record UpdateReadingProgressCommand(
string PageId,
string UserId,
Guid EbookId,
double Progress,
string? ChapterTitle,
int ChapterIndex,
string? ExcludedConnectionId = null) : IRequest<Result>;
@@ -0,0 +1,7 @@
namespace NexusReader.Application.DTOs.User;
public record AuthorDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
}
@@ -4,6 +4,7 @@ public record UserProfileDto
{
public string Email { get; init; } = string.Empty;
public int AITokensUsed { get; init; }
public Guid TenantId { get; init; }
/// <summary>
/// Relational data for the current subscription plan.
@@ -22,4 +23,9 @@ public record LastReadBookDto
{
public Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
public AuthorDto Author { get; init; } = new();
public string? CoverUrl { get; init; }
public double Progress { get; init; }
public string? LastChapter { get; init; }
public int LastChapterIndex { get; init; }
}
@@ -2,4 +2,4 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Reader;
public record GetReaderPageQuery(int ChapterIndex = 0) : IQuery<ReaderPageViewModel>;
public record GetReaderPageQuery(int ChapterIndex = 0, string? UserId = null) : IQuery<ReaderPageViewModel>;
@@ -15,6 +15,6 @@ internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQue
public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
{
return await _epubService.GetEpubContentAsync(request.ChapterIndex);
return await _epubService.GetEpubContentAsync(request.ChapterIndex, request.UserId);
}
}
@@ -8,4 +8,4 @@ public abstract record ContentBlock(string Id);
public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id);
public record AiActionTriggerBlock(string Id, string Dialogue, List<string> ActionOptions) : ContentBlock(Id);
public record ReaderPageViewModel(List<ContentBlock> Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle);
public record ReaderPageViewModel(List<ContentBlock> Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle, Guid EbookId = default);
@@ -0,0 +1,7 @@
using MediatR;
using FluentResults;
using NexusReader.Application.DTOs.User;
namespace NexusReader.Application.Queries.User;
public record GetUserProfileQuery(string UserId) : IRequest<Result<UserProfileDto>>;
@@ -0,0 +1,62 @@
using MediatR;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.User;
public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, Result<UserProfileDto>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetUserProfileQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var profile = await dbContext.Users
.Where(u => u.Id == request.UserId)
.Select(u => new UserProfileDto
{
Email = u.Email ?? string.Empty,
AITokensUsed = u.AITokensUsed,
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{
Id = u.SubscriptionPlan.Id,
Name = u.SubscriptionPlan.PlanName,
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0)
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
: 0,
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{
Id = e.Id,
Title = e.Title,
Author = new AuthorDto
{
Id = e.Author.Id,
Name = e.Author.Name
},
CoverUrl = e.CoverUrl,
Progress = e.Progress,
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
LastChapterIndex = e.LastChapterIndex
}).FirstOrDefault()
})
.FirstOrDefaultAsync(cancellationToken);
if (profile == null)
{
return Result.Fail("Profile not found.");
}
return Result.Ok(profile);
}
}