feat(ui): implement hub navigation, profile dashboard and fix auth sync loop

- Added MainHubLayout with glassmorphism sidebar
- Implemented Profile dashboard with learn metrics
- Added request deduplication and caching to IdentityService
- Fixed infinite redirect loop on /profile page
- Added dashboard navigation from reader
- Closes #26, Closes #27
This commit is contained in:
2026-05-10 09:28:40 +02:00
parent 34794db209
commit 5fdc89dbf3
22 changed files with 1402 additions and 343 deletions
@@ -4,6 +4,7 @@ public record UserProfileDto
{ {
public string Email { get; init; } = string.Empty; public string Email { get; init; } = string.Empty;
public int AITokensUsed { get; init; } public int AITokensUsed { get; init; }
public Guid TenantId { get; init; }
/// <summary> /// <summary>
/// Relational data for the current subscription plan. /// Relational data for the current subscription plan.
+2 -2
View File
@@ -3,7 +3,7 @@
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly"> <Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainHubLayout)">
<NotAuthorized> <NotAuthorized>
<RedirectToLogin /> <RedirectToLogin />
</NotAuthorized> </NotAuthorized>
@@ -11,7 +11,7 @@
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found> </Found>
<NotFound> <NotFound>
<LayoutView Layout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)"> <LayoutView Layout="@typeof(NexusReader.UI.Shared.Layout.MainHubLayout)">
<p role="alert">Sorry, there's nothing at this address.</p> <p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView> </LayoutView>
</NotFound> </NotFound>
@@ -1,6 +1,27 @@
<svg class="nexus-icon @Class" viewBox="0 0 24 24" fill="currentColor" width="@Size" height="@Size" @attributes="AdditionalAttributes"> <svg class="nexus-icon @Class" viewBox="0 0 24 24" fill="currentColor" width="@Size" height="@Size" @attributes="AdditionalAttributes">
@switch (Name.ToLowerInvariant()) @switch (Name.ToLowerInvariant())
{ {
case "home":
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "map":
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "share-2":
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="18" cy="19" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "help-circle":
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="12" y1="17" x2="12.01" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "robot": case "robot":
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" /> <path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
break; break;
@@ -16,8 +37,24 @@
case "message-square": case "message-square":
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
break; break;
case "diamond":
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "layout":
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "book-open":
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "user":
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "settings": case "settings":
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /> <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "bookmark": case "bookmark":
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -7,8 +7,8 @@
<aside class="intelligence-toolbar"> <aside class="intelligence-toolbar">
<div class="toolbar-top"> <div class="toolbar-top">
<button class="toolbar-item" title="Back"> <button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Back to Dashboard">
<NexusIcon Name="play" Size="20" Class="rotate-180" /> <NexusIcon Name="arrow-left" Size="20" />
</button> </button>
<button class="toolbar-item active" title="Chat"> <button class="toolbar-item active" title="Chat">
<NexusIcon Name="message-square" Size="20" /> <NexusIcon Name="message-square" Size="20" />
@@ -35,8 +35,8 @@
@onclick="FocusMode.ToggleAsync" title="Focus Mode (F)"> @onclick="FocusMode.ToggleAsync" title="Focus Mode (F)">
<NexusIcon Name="target" Size="20" /> <NexusIcon Name="target" Size="20" />
</button> </button>
<button class="toolbar-item" title="Global Settings"> <button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Global Hub">
<NexusIcon Name="settings" Size="20" /> <NexusIcon Name="layers" Size="20" />
</button> </button>
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit"> <button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
<NexusIcon Name="log-out" Size="20" /> <NexusIcon Name="log-out" Size="20" />
@@ -0,0 +1,106 @@
@inherits LayoutComponentBase
@using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Services
<div class="hub-container">
<AuthorizeView>
<Authorized>
<aside class="hub-sidebar">
<div class="sidebar-header">
<div class="logo">
<NexusIcon Name="diamond" Size="24" Class="logo-icon" />
<span class="logo-text">Nexus</span>
</div>
</div>
<nav class="sidebar-nav">
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All">
<div class="nav-icon">
<NexusIcon Name="home" Size="18" />
</div>
<span class="nav-text">Dashboard</span>
</NavLink>
<NavLink class="nav-item" href="/library">
<div class="nav-icon">
<NexusIcon Name="book-open" Size="18" />
</div>
<span class="nav-text">Library</span>
</NavLink>
<NavLink class="nav-item" href="/concepts-map">
<div class="nav-icon">
<NexusIcon Name="map" Size="18" />
</div>
<span class="nav-text">Concepts Map</span>
</NavLink>
<NavLink class="nav-item" href="/profile">
<div class="nav-icon">
<NexusIcon Name="message-square" Size="18" />
</div>
<span class="nav-text">Profile</span>
</NavLink>
<NavLink class="nav-item" href="/settings">
<div class="nav-icon">
<NexusIcon Name="settings" Size="18" />
</div>
<span class="nav-text">Settings</span>
</NavLink>
<NavLink class="nav-item" href="/concenters">
<div class="nav-icon">
<NexusIcon Name="target" Size="18" />
</div>
<span class="nav-text">Concenters</span>
</NavLink>
</nav>
<div class="sidebar-footer">
<div class="user-brief">
<div class="user-avatar">
@context.User.Identity?.Name?[0].ToString().ToUpper()
</div>
<div class="user-details">
<span class="user-name">@context.User.Identity?.Name</span>
</div>
</div>
<button class="logout-btn" @onclick="HandleLogout" title="Logout">
<NexusIcon Name="log-out" Size="18" />
</button>
</div>
</aside>
</Authorized>
</AuthorizeView>
<main class="hub-main">
<div class="hub-content">
@Body
</div>
</main>
</div>
@code {
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private IIdentityService IdentityService { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
private bool _isSyncing = false;
protected override async Task OnInitializedAsync()
{
if (_isSyncing) return;
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.Identity?.IsAuthenticated ?? true)
{
_isSyncing = true;
// Try to sync with server cookie
await IdentityService.GetProfileAsync();
}
}
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/", true);
}
}
@@ -0,0 +1,193 @@
.hub-container {
display: flex;
width: 100vw;
height: 100vh;
background: #121212;
color: #e0e0e0;
overflow: hidden;
}
::deep .hub-sidebar {
width: 260px;
height: 100%;
background: #161616;
border-right: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
z-index: 100;
flex-shrink: 0;
}
::deep .sidebar-header {
padding: 2.5rem 1.5rem;
}
::deep .logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
::deep .logo-icon {
color: var(--nexus-neon);
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.4));
}
::deep .logo-text {
font-family: var(--nexus-font-serif);
font-size: 1.5rem;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.01em;
}
::deep .sidebar-nav {
flex: 1;
padding: 0;
display: flex;
flex-direction: column;
}
::deep .nav-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
color: #A0A0A0;
text-decoration: none;
transition: all 0.2s ease;
border-left: 3px solid transparent;
font-family: var(--nexus-font-sans);
font-size: 0.9rem;
font-weight: 500;
}
::deep .nav-item:hover {
background: rgba(255, 255, 255, 0.02);
color: #ffffff;
}
::deep .nav-item.active {
color: #ffffff;
background: rgba(0, 255, 153, 0.03);
border-left: 3px solid var(--nexus-neon);
}
::deep .nav-item.active .nav-icon {
color: var(--nexus-neon);
}
::deep .nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
opacity: 0.7;
transition: opacity 0.2s;
}
::deep .nav-item:hover .nav-icon,
::deep .nav-item.active .nav-icon {
opacity: 1;
}
::deep .sidebar-footer {
padding: 1.25rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
}
::deep .user-brief {
display: flex;
align-items: center;
gap: 0.75rem;
overflow: hidden;
}
::deep .user-avatar {
width: 32px;
height: 32px;
background: #222;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 600;
color: #A0A0A0;
flex-shrink: 0;
}
::deep .user-details {
display: flex;
flex-direction: column;
overflow: hidden;
}
::deep .user-name {
font-size: 0.85rem;
font-weight: 500;
color: #A0A0A0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
::deep .logout-btn {
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 0.4rem;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
::deep .logout-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
.hub-main {
flex: 1;
height: 100%;
overflow-y: auto;
background: radial-gradient(circle at center, #1a1a1a 0%, #121212 100%);
}
.hub-content {
padding: 2.5rem;
min-height: 100%;
}
::deep .hub-loading {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
::deep .nexus-loader {
width: 32px;
height: 32px;
border: 2px solid rgba(0, 255, 153, 0.1);
border-top-color: var(--nexus-neon);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
filter: drop-shadow(0 0 5px var(--nexus-neon));
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -10,19 +10,23 @@
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject Microsoft.Extensions.Logging.ILogger<MainLayout> Logger @inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
@implements IDisposable @implements IDisposable
<AuthorizeView> <div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
<Authorized> <div class="reader-pane">
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")"> <main>
<div class="reader-pane"> @Body
<main> </main>
@Body <AuthorizeView>
</main> <Authorized>
<ReaderFooter /> <ReaderFooter />
</div> </Authorized>
</AuthorizeView>
</div>
<AuthorizeView>
<Authorized>
<div class="resizer" id="sidebar-resizer"></div> <div class="resizer" id="sidebar-resizer"></div>
<div class="intelligence-sidebar"> <div class="intelligence-sidebar">
@@ -34,9 +38,6 @@
Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" /> Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span> <span>Asystent AI</span>
</div> </div>
<button class="close-btn">×</button> <button class="close-btn">×</button>
</div> </div>
@@ -49,18 +50,15 @@
</div> </div>
</div> </div>
</div> </div>
</div> </Authorized>
</Authorized> <Authorizing>
<Authorizing> <div class="app-preloader">
<div class="app-preloader"> <div class="preloader-spinner"></div>
<div class="preloader-spinner"></div> <div class="preloader-text">Weryfikacja...</div>
<div class="preloader-text">Weryfikacja...</div> </div>
</div> </Authorizing>
</Authorizing> </AuthorizeView>
<NotAuthorized> </div>
@Body
</NotAuthorized>
</AuthorizeView>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred. An unhandled error has occurred.
@@ -21,9 +21,8 @@
<div class="social-auth"> <div class="social-auth">
<button type="button" class="btn-google-auth" @onclick="HandleGoogleLogin"> <button type="button" class="btn-google-auth" @onclick="HandleGoogleLogin">
<img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png" <img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png" alt="Google"
alt="Google" style="width: 20px !important; height: 20px !important; flex-shrink: 0;" />
style="width: 20px !important; height: 20px !important; flex-shrink: 0;" />
<span>Zaloguj się przez Google</span> <span>Zaloguj się przez Google</span>
</button> </button>
</div> </div>
@@ -47,7 +46,8 @@
<div class="field-icon"> <div class="field-icon">
<NexusIcon Name="lock" Size="18" /> <NexusIcon Name="lock" Size="18" />
</div> </div>
<InputText id="password" type="@(_showPassword ? "text" : "password")" @bind-Value="_loginModel.Password" placeholder="Hasło" class="field-input" /> <InputText id="password" type="@(_showPassword ? "text" : "password")"
@bind-Value="_loginModel.Password" placeholder="Hasło" class="field-input" />
<button type="button" class="toggle-visibility" @onclick="TogglePassword"> <button type="button" class="toggle-visibility" @onclick="TogglePassword">
<NexusIcon Name="@(_showPassword ? "eye-off" : "eye")" Size="18" /> <NexusIcon Name="@(_showPassword ? "eye-off" : "eye")" Size="18" />
</button> </button>
@@ -84,7 +84,8 @@
</div> </div>
<div class="auth-legal"> <div class="auth-legal">
Korzystając z usługi, akceptujesz <a href="/terms">Regulamin</a> i <a href="/privacy">Politykę Prywatności</a> Korzystając z usługi, akceptujesz <a href="/terms">Regulamin</a> i <a href="/privacy">Politykę
Prywatności</a>
</div> </div>
</div> </div>
</div> </div>
@@ -125,7 +126,7 @@
if (success) NavigationManager.NavigateTo("/"); if (success) NavigationManager.NavigateTo("/");
else _errorMessage = "Nieprawidłowy e-mail lub hasło."; else _errorMessage = "Nieprawidłowy e-mail lub hasło.";
} }
catch (Exception) { _errorMessage = "Wystąpił błąd logowania."; } catch (Exception ex) { _errorMessage = $"Wystąpił błąd logowania: {ex.Message}."; }
finally { _isSubmitting = false; } finally { _isSubmitting = false; }
} }
@@ -1,4 +1,5 @@
@page "/account/profile" @page "/account/profile"
@page "/profile"
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@@ -6,96 +7,102 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="profile-dashboard"> <div class="profile-page-container">
<div class="background-radial"></div>
<div class="mesh-overlay"></div> <div class="mesh-overlay"></div>
@if (_profile == null) @if (_profile == null)
{ {
<div class="loading-overlay"> <div class="loading-state">
<div class="nexus-loader"></div> <div class="nexus-loader"></div>
<p>Ładowanie Twojego profilu...</p> <p>Ładowanie systemu...</p>
</div> </div>
} }
else else
{ {
<div class="dashboard-content"> <div class="profile-content">
<header class="dashboard-header"> <!-- Identity Section -->
<div class="user-meta"> <section class="identity-section">
<div class="user-avatar"> <div class="avatar-container">
<div class="avatar-glow"></div>
<div class="avatar-inner">
@(_profile.Email[0].ToString().ToUpper()) @(_profile.Email[0].ToString().ToUpper())
</div> </div>
<div class="user-info">
<h1>@_profile.Email</h1>
<div class="plan-info">
<span class="badge @(_profile.CurrentPlan.ToLower())">@_profile.CurrentPlan Plan</span>
<span class="tenant-id">ID: @_profile.TenantId.ToString()[..8]...</span>
</div>
</div>
</div> </div>
<div class="header-actions">
<button class="btn-logout" @onclick="HandleLogout">
<NexusIcon Name="lock" Size="16" />
Wyloguj się
</button>
</div>
</header>
<div class="stats-grid"> <div class="user-titles">
<!-- AI Token Card --> <h1 class="username">@_profile.Email.Split('@')[0]</h1>
<div class="stat-card usage-card"> <span class="system-rank">[Nexus_Explorer_@(_profile.TenantId.ToString()[..4])]</span>
<div class="card-icon"> </div>
<NexusIcon Name="robot" Size="24" /> </section>
<!-- Metrics Grid -->
<div class="metrics-grid">
<!-- Intelligence Card -->
<div class="metric-card glass-panel">
<div class="card-header">
<NexusIcon Name="robot" Size="24" Color="var(--nexus-neon)" />
<h3>Interfejs AI</h3>
</div> </div>
<div class="card-info"> <div class="card-body">
<h3>Wykorzystanie AI</h3> <div class="token-usage">
<div class="token-numbers"> <div class="usage-values">
<span class="tokens-used">@_profile.AITokensUsed</span> <span class="current">@_profile.AITokensUsed</span>
<span class="tokens-limit">/ @_profile.AITokenLimit tokenów</span> <span class="separator">/</span>
<span class="total">@_profile.AITokenLimit</span>
</div>
<div class="usage-progress">
<div class="progress-bar" style="width: @(CalculateProgress())%"></div>
</div>
</div> </div>
<div class="usage-bar"> <span class="metric-label">Wykorzystane Jednostki Mocy</span>
<div class="usage-fill" style="width: @(CalculateProgress())%"></div>
</div>
<p class="usage-desc">Limit odnawia się w następnym cyklu rozliczeniowym.</p>
</div> </div>
</div> </div>
<!-- Learning Progress Card --> <!-- Sync Card -->
<div class="stat-card learning-card"> <div class="metric-card glass-panel">
<div class="card-icon"> <div class="card-header">
<NexusIcon Name="mail" Size="24" /> <NexusIcon Name="activity" Size="24" Color="var(--nexus-neon)" />
<h3>Wydajność Nauki</h3>
</div> </div>
<div class="card-info"> <div class="card-body">
<h3>Aktywna Nauka</h3> <div class="score-display">
<div class="learning-metrics"> <span class="score-value">@_profile.AverageQuizScore%</span>
<div class="metric"> <span class="score-label">Średni Wynik Asymilacji</span>
<span class="label">Średni wynik quizów</span> </div>
<span class="value">@_profile.AverageQuizScore%</span> <div class="last-book">
</div> <NexusIcon Name="book-open" Size="14" />
<div class="metric"> <span class="truncate">@_profile.LastReadBookTitle</span>
<span class="label">Ostatnio czytane</span> </div>
<span class="value truncate">@_profile.LastReadBookTitle</span> </div>
</div> </div>
<!-- Account Status Card -->
<div class="metric-card glass-panel full-width">
<div class="card-header">
<NexusIcon Name="shield" Size="24" Color="var(--nexus-neon)" />
<h3>Status Autoryzacji</h3>
</div>
<div class="card-body status-layout">
<div class="status-info">
<span class="plan-badge @(_profile.CurrentPlan.ToLower())">@_profile.CurrentPlan Protocol</span>
<span class="tenant-tag">Node: @_profile.TenantId.ToString().ToUpper()</span>
</div>
<div class="profile-actions">
<button class="btn-nexus secondary" @onclick="HandleUpgrade">Zarządzaj Subskrypcją</button>
<button class="btn-nexus logout" @onclick="HandleLogout">
<NexusIcon Name="log-out" Size="18" />
Wyloguj
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<section class="subscription-section">
<div class="section-card">
<div class="section-info">
<h2>Zarządzaj subskrypcją</h2>
<p>Zmień swój plan, aby zwiększyć limit tokenów AI i odblokować funkcje premium.</p>
</div>
<button class="btn-upgrade" @onclick="HandleUpgrade">
Przejdź do panelu płatności
</button>
</div>
</section>
</div> </div>
} }
<div class="decoration-star top-left">✦</div> <div class="decoration decoration-top">NXS-SYS-v10</div>
<div class="decoration-star bottom-right">✦</div> <div class="decoration decoration-bottom">IDENTITY-CORE-ENCRYPTED</div>
</div> </div>
@code { @code {
@@ -104,6 +111,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_profile = await IdentityService.GetProfileAsync(); _profile = await IdentityService.GetProfileAsync();
StateHasChanged();
} }
private int CalculateProgress() private int CalculateProgress()
@@ -1,256 +1,361 @@
.profile-dashboard { .profile-page-container {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background-color: #121418; background-color: #0a0c10;
color: white; color: #e0e6ed;
font-family: 'Inter', sans-serif; overflow-x: hidden;
padding: 60px 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow-x: hidden; padding: 80px 20px;
font-family: var(--nexus-font-sans);
}
.background-radial {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 800px;
height: 800px;
background: radial-gradient(circle, rgba(0, 255, 153, 0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
} }
.mesh-overlay { .mesh-overlay {
position: absolute; position: absolute;
top: 0; left: 0; width: 100%; height: 100%; top: 0; left: 0; width: 100%; height: 100%;
background-image: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.02) 1px, transparent 0); background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.02) 1px, transparent 0);
background-size: 40px 40px; background-size: 32px 32px;
z-index: 1; z-index: 1;
} }
.dashboard-content { .profile-content {
position: relative; position: relative;
width: 100%;
max-width: 1000px;
z-index: 10; z-index: 10;
} width: 100%;
max-width: 900px;
.dashboard-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 48px; gap: 60px;
padding: 0 10px;
} }
.user-meta { /* Identity Section */
.identity-section {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 24px; gap: 24px;
text-align: center;
} }
.user-avatar { .avatar-container {
width: 80px; position: relative;
height: 80px; width: 140px;
background: linear-gradient(135deg, #44ff77 0%, #2ecc71 100%); height: 140px;
border-radius: 24px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-size: 2.5rem; }
.avatar-inner {
width: 120px;
height: 120px;
background: #151921;
border: 2px solid var(--nexus-neon);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-size: 3.5rem;
font-weight: 800; font-weight: 800;
color: #000; color: var(--nexus-neon);
box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2); z-index: 2;
box-shadow: 0 0 30px rgba(0, 255, 153, 0.2), inset 0 0 20px rgba(0, 255, 153, 0.1);
position: relative;
} }
.user-info h1 { .avatar-glow {
font-size: 1.8rem; position: absolute;
width: 140px;
height: 140px;
border: 1px solid rgba(0, 255, 153, 0.3);
border-radius: 50%;
animation: pulse-ring 3s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes pulse-ring {
0% { transform: scale(0.95); opacity: 0.8; }
50% { transform: scale(1.1); opacity: 0.2; }
100% { transform: scale(0.95); opacity: 0.8; }
}
.username {
font-family: var(--nexus-font-serif);
font-size: 2.8rem;
font-weight: 700; font-weight: 700;
margin: 0 0 8px; margin: 0;
letter-spacing: -0.02em; letter-spacing: -0.01em;
color: #ffffff;
} }
.plan-info { .system-rank {
font-family: 'Inter', 'Courier New', Courier, monospace;
font-size: 0.9rem;
color: var(--nexus-neon);
text-transform: uppercase;
letter-spacing: 0.2em;
opacity: 0.8;
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
width: 100%;
}
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 32px;
transition: all 0.3s ease;
}
.glass-panel:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 255, 153, 0.2);
transform: translateY(-4px);
}
.metric-card {
display: flex;
flex-direction: column;
gap: 24px;
}
.full-width {
grid-column: span 2;
}
.card-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.badge { .card-header h3 {
padding: 4px 12px; font-size: 0.9rem;
border-radius: 8px; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #a0aec0;
margin: 0;
}
.card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Usage Progress */
.token-usage {
display: flex;
flex-direction: column;
gap: 12px;
}
.usage-values {
display: flex;
align-items: baseline;
gap: 8px;
}
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: #fff; line-height: 1; }
.usage-values .separator { font-size: 1.2rem; color: #4a5568; }
.usage-values .total { font-size: 1.2rem; color: #718096; font-weight: 600; }
.usage-progress {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--nexus-neon);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.5);
border-radius: 10px;
}
.metric-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; color: #718096;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.badge.pro { background: rgba(68, 255, 119, 0.1); color: #44ff77; border: 1px solid rgba(68, 255, 119, 0.2); } /* Score Display */
.badge.free { background: rgba(255, 255, 255, 0.05); color: #888; border: 1px solid rgba(255, 255, 255, 0.1); } .score-display {
display: flex;
flex-direction: column;
}
.tenant-id { font-size: 0.8rem; color: #555; } .score-value {
font-size: 2.5rem;
font-weight: 800;
color: var(--nexus-neon);
line-height: 1;
}
.btn-logout { .score-label {
font-size: 0.75rem;
color: #718096;
text-transform: uppercase;
margin-top: 4px;
}
.last-book {
margin-top: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px 18px; padding: 10px 16px;
background: rgba(255, 255, 255, 0.03); background: rgba(0, 255, 153, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px; border-radius: 12px;
color: #888; font-size: 0.85rem;
font-size: 0.9rem; color: #cbd5e0;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
} }
.btn-logout:hover { .truncate {
background: rgba(255, 77, 77, 0.05); white-space: nowrap;
border-color: rgba(255, 77, 77, 0.2);
color: #ff4d4d;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 48px;
}
.stat-card {
background: #1c1f24;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 32px;
display: flex;
gap: 24px;
transition: transform 0.3s ease;
}
.stat-card:hover { transform: translateY(-5px); }
.card-icon {
width: 56px;
height: 56px;
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
display: flex;
justify-content: center;
align-items: center;
color: #44ff77;
flex-shrink: 0;
}
.card-info h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 16px;
color: #e0e0e0;
}
.token-numbers {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
}
.tokens-used { font-size: 2rem; font-weight: 800; color: white; }
.tokens-limit { font-size: 1rem; color: #555; font-weight: 500; }
.usage-bar {
width: 100%;
height: 8px;
background: #15181c;
border-radius: 10px;
overflow: hidden; overflow: hidden;
margin-bottom: 12px; text-overflow: ellipsis;
max-width: 300px;
} }
.usage-fill { /* Status Layout */
height: 100%; .status-layout {
background: #44ff77; flex-direction: row;
box-shadow: 0 0 15px rgba(68, 255, 119, 0.3);
border-radius: 10px;
}
.usage-desc { font-size: 0.8rem; color: #555; margin: 0; }
.learning-metrics {
display: flex;
flex-direction: column;
gap: 20px;
}
.metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric .label { font-size: 0.85rem; color: #666; font-weight: 500; }
.metric .value { font-size: 1.2rem; font-weight: 700; color: white; }
.metric .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
.subscription-section { margin-top: 48px; }
.section-card {
background: linear-gradient(90deg, #1c1f24 0%, #23272e 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 40px;
display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 40px;
} }
.section-info h2 { font-size: 1.5rem; font-weight: 700; margin: 0 0 12px; } .status-info {
.section-info p { color: #888; margin: 0; line-height: 1.6; max-width: 500px; } display: flex;
flex-direction: column;
gap: 8px;
}
.btn-upgrade { .plan-badge {
padding: 16px 32px; padding: 6px 14px;
background: #44ff77; border-radius: 8px;
border: none; font-size: 0.8rem;
border-radius: 16px; font-weight: 800;
color: #000; text-transform: uppercase;
font-size: 1rem; letter-spacing: 0.05em;
width: fit-content;
}
.plan-badge.pro {
background: rgba(0, 255, 153, 0.1);
color: var(--nexus-neon);
border: 1px solid rgba(0, 255, 153, 0.2);
}
.tenant-tag {
font-family: monospace;
font-size: 0.75rem;
color: #4a5568;
}
.profile-actions {
display: flex;
gap: 16px;
}
.btn-nexus {
padding: 12px 24px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s ease;
white-space: nowrap; display: flex;
align-items: center;
gap: 8px;
border: none;
} }
.btn-upgrade:hover { .btn-nexus.secondary {
transform: scale(1.05); background: rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2); color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
} }
.loading-overlay { .btn-nexus.secondary:hover {
background: rgba(255, 255, 255, 0.1);
}
.btn-nexus.logout {
background: rgba(255, 71, 87, 0.1);
color: #ff4757;
border: 1px solid rgba(255, 71, 87, 0.2);
}
.btn-nexus.logout:hover {
background: #ff4757;
color: #fff;
}
/* Decorations */
.decoration {
position: absolute;
font-family: monospace;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.05);
letter-spacing: 0.3em;
pointer-events: none;
}
.decoration-top { top: 40px; left: 40px; }
.decoration-bottom { bottom: 40px; right: 40px; }
/* Loading State */
.loading-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 20px; gap: 24px;
} }
.nexus-loader { .nexus-loader {
width: 48px; width: 60px;
height: 48px; height: 60px;
border: 3px solid rgba(68, 255, 119, 0.1); border: 4px solid rgba(0, 255, 153, 0.1);
border-top-color: #44ff77; border-top-color: var(--nexus-neon);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite;
filter: drop-shadow(0 0 10px var(--nexus-neon));
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.decoration-star {
position: absolute;
font-size: 48px;
color: rgba(255, 255, 255, 0.03);
z-index: 1;
}
.top-left { top: 40px; left: 40px; }
.bottom-right { bottom: 40px; right: 40px; }
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-header { flex-direction: column; align-items: flex-start; gap: 24px; } .metrics-grid { grid-template-columns: 1fr; }
.header-actions { width: 100%; } .full-width { grid-column: span 1; }
.btn-logout { width: 100%; justify-content: center; } .status-layout { flex-direction: column; align-items: flex-start; gap: 24px; }
.stats-grid { grid-template-columns: 1fr; } .profile-actions { width: 100%; flex-direction: column; }
.section-card { flex-direction: column; text-align: center; } .btn-nexus { width: 100%; justify-content: center; }
.username { font-size: 2.2rem; }
} }
@@ -0,0 +1,112 @@
@page "/"
@using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Components.Atoms
@attribute [Authorize]
<PageTitle>Dashboard | Nexus Reader</PageTitle>
<div class="dashboard-container">
<!-- Top Profile Section -->
<header class="profile-header">
<div class="header-grid-bg"></div>
<div class="profile-visual">
<div class="avatar-wrapper">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=Nexus" alt="Profile" class="profile-img" />
<div class="avatar-glow"></div>
</div>
<h1 class="username">[User_Explorer1988]</h1>
<div class="status-pills">
<div class="status-pill">
<span class="pill-label">Books Read:</span>
<span class="pill-value">12</span>
</div>
<div class="status-pill">
<span class="pill-label">Concepts Mapped:</span>
<span class="pill-value">450</span>
</div>
<div class="status-pill">
<span class="pill-label">Quiz Mastery:</span>
<span class="pill-value">88%</span>
</div>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="dashboard-content">
<h2 class="section-title">User: [User Name]</h2>
<div class="main-grid">
<!-- Current Reading Card -->
<section class="reading-card glass-panel">
<div class="card-header">
<h3>Current Reading: The History of Art (Chapter 3: Renaissance Masters)</h3>
</div>
<div class="card-body">
<div class="reading-thumb">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg/402px-Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg" alt="Current Book" />
</div>
<div class="reading-info">
<div class="progress-section">
<span class="chapter-label">Chapter 3: Renaissance Masters</span>
<div class="progress-container">
<div class="progress-bar" style="width: 45%;">
<div class="progress-bubble">45%</div>
</div>
</div>
<span class="progress-detail">Progress: 45% - Section 3.2</span>
</div>
<p class="reading-desc">
The history of art is a mart eccohow, and andosum and tomeam of the inner otium or orer the sllinest arts and emoti mooners in the tour of arts and specillers. Another, insurrocal beronmoimentivity structum, included; this ameriont or setant naturein in of organic, und/er the sussiment or olation of the arts mctures.
</p>
<div class="card-actions">
<button class="btn-nexus primary">Continue Reading</button>
<button class="btn-nexus secondary">Open Reader</button>
</div>
</div>
</div>
</section>
<div class="secondary-grid">
<!-- Knowledge Integration -->
<section class="integration-card glass-panel">
<div class="panel-header">
<h4>Knowledge Integration Progress</h4>
<NexusIcon Name="arrow-right" Size="16" />
</div>
<div class="graph-placeholder">
<div class="graph-node central"></div>
<div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
<div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
<div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
<div class="active-node-label">TU JESTEŚ</div>
</div>
</section>
<!-- Quiz Summary -->
<section class="quiz-card glass-panel">
<div class="panel-header">
<h4>Quiz Summary: Key Thinkers</h4>
<NexusIcon Name="arrow-right" Size="16" />
</div>
<div class="quiz-preview">
<p class="question">Który artysta namalował 'Ostatnią Wieczerzę'?</p>
<div class="quiz-options">
<div class="quiz-option active">
<span class="option-letter">A)</span> Michal Anioł
</div>
<div class="quiz-option">
<span class="option-letter">B)</span> Leonardo da Vinci
</div>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
@code {
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
}
@@ -0,0 +1,358 @@
.dashboard-container {
min-height: 100%;
display: flex;
flex-direction: column;
animation: fade-in 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* --- Profile Header --- */
.profile-header {
position: relative;
padding: 4rem 2rem 3rem;
display: flex;
justify-content: center;
overflow: hidden;
background: #0D0D0D;
}
.header-grid-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 60px 60px;
background-position: center;
mask-image: radial-gradient(circle at center, black, transparent 80%);
}
.profile-visual {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.avatar-wrapper {
position: relative;
width: 120px;
height: 120px;
}
.profile-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 3px solid #1a1a1a;
position: relative;
z-index: 2;
background: #222;
}
.avatar-glow {
position: absolute;
inset: -5px;
border-radius: 50%;
background: var(--nexus-neon);
filter: blur(20px);
opacity: 0.4;
z-index: 1;
animation: pulse-glow 3s infinite;
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); opacity: 0.4; }
50% { transform: scale(1.05); opacity: 0.6; }
}
.username {
font-family: var(--nexus-font-sans);
font-size: 1.25rem;
font-weight: 500;
color: #ffffff;
letter-spacing: 1px;
}
.status-pills {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.status-pill {
padding: 0.6rem 1.25rem;
background: rgba(0, 255, 153, 0.05);
border: 1px solid rgba(0, 255, 153, 0.3);
border-radius: 100px;
display: flex;
gap: 0.5rem;
font-size: 0.9rem;
box-shadow: 0 0 15px rgba(0, 255, 153, 0.1);
}
.pill-label { color: #A0A0A0; }
.pill-value { color: #ffffff; font-weight: 600; }
/* --- Dashboard Content --- */
.dashboard-content {
padding: 3rem 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.section-title {
font-family: var(--nexus-font-serif);
font-size: 2rem;
margin-bottom: 2rem;
color: #ffffff;
}
.main-grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 2rem;
}
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 1.5rem;
}
/* Reading Card */
.reading-card {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.reading-card h3 {
font-size: 1.1rem;
font-weight: 600;
color: #E0E0E0;
margin: 0;
}
.card-body {
display: flex;
gap: 2rem;
}
.reading-thumb {
width: 120px;
flex-shrink: 0;
}
.reading-thumb img {
width: 100%;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.reading-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chapter-label {
font-size: 0.85rem;
color: #A0A0A0;
}
.progress-container {
height: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
position: relative;
}
.progress-bar {
height: 100%;
background: var(--nexus-neon);
border-radius: 4px;
position: relative;
box-shadow: 0 0 10px rgba(0, 255, 153, 0.3);
}
.progress-bubble {
position: absolute;
right: -20px;
top: -30px;
background: var(--nexus-neon);
color: #000;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 700;
}
.progress-detail {
font-size: 0.8rem;
color: #666;
}
.reading-desc {
font-size: 0.85rem;
line-height: 1.6;
color: #888;
margin: 0;
}
.card-actions {
display: flex;
gap: 1rem;
}
/* Secondary Grid Items */
.secondary-grid {
display: flex;
flex-direction: column;
gap: 2rem;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.panel-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #E0E0E0;
}
/* Graph Placeholder */
.graph-placeholder {
height: 180px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.graph-node {
position: absolute;
border-radius: 50%;
background: #333;
border: 1px solid rgba(255,255,255,0.1);
}
.graph-node.central {
width: 40px;
height: 40px;
background: var(--nexus-neon);
box-shadow: 0 0 20px rgba(0, 255, 153, 0.4);
}
.graph-node.satellite {
width: 20px;
height: 20px;
transform: rotate(var(--angle)) translateY(var(--dist));
}
.active-node-label {
position: absolute;
bottom: 20px;
right: 20px;
padding: 4px 12px;
background: rgba(0, 255, 153, 0.1);
border: 1px solid var(--nexus-neon);
border-radius: 4px;
font-size: 0.7rem;
color: var(--nexus-neon);
font-weight: 700;
}
/* Quiz Preview */
.quiz-preview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.question {
font-size: 0.95rem;
color: #E0E0E0;
}
.quiz-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.quiz-option {
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
font-size: 0.9rem;
display: flex;
gap: 0.75rem;
cursor: pointer;
}
.quiz-option.active {
background: rgba(0, 255, 153, 0.05);
border-color: var(--nexus-neon);
color: var(--nexus-neon);
}
.option-letter {
font-weight: 700;
}
/* Buttons */
.btn-nexus {
padding: 0.75rem 1.25rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-nexus.primary {
background: var(--nexus-neon);
color: #000;
}
.btn-nexus.secondary {
background: rgba(255, 255, 255, 0.05);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-nexus:hover {
transform: translateY(-2px);
filter: brightness(1.1);
}
@media (max-width: 1024px) {
.main-grid {
grid-template-columns: 1fr;
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
@page "/" @page "/reader"
@layout ReaderLayout
@attribute [Authorize] @attribute [Authorize]
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@implements IAsyncDisposable @implements IAsyncDisposable
@@ -0,0 +1,17 @@
@page "/library"
@attribute [Authorize]
<div class="library-page">
<h1>Biblioteka</h1>
<p>Twoja kolekcja książek i dokumentów pojawi się tutaj wkrótce.</p>
</div>
<style>
.library-page {
padding: 2rem;
}
h1 {
margin-bottom: 1rem;
color: #fff;
}
</style>
@@ -1,5 +1,5 @@
@page "/not-found" @page "/not-found"
@layout Layout.MainLayout @layout MainHubLayout
<div class="not-found-preloader"> <div class="not-found-preloader">
<div class="preloader-robot"> <div class="preloader-robot">
@@ -0,0 +1,17 @@
@page "/settings"
@attribute [Authorize]
<div class="settings-page">
<h1>Ustawienia</h1>
<p>Konfiguracja Twojego konta i preferencji czytania.</p>
</div>
<style>
.settings-page {
padding: 2rem;
}
h1 {
margin-bottom: 1rem;
color: #fff;
}
</style>
+1 -1
View File
@@ -2,7 +2,7 @@
<ChildContent> <ChildContent>
<Router AppAssembly="@typeof(Routes).Assembly"> <Router AppAssembly="@typeof(Routes).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainHubLayout)">
<NotAuthorized> <NotAuthorized>
<RedirectToLogin /> <RedirectToLogin />
</NotAuthorized> </NotAuthorized>
@@ -1,5 +1,7 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.User;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
@@ -14,25 +16,32 @@ public interface IIdentityService
public record UserProfile( public record UserProfile(
string Email, string Email,
int AITokenLimit,
int AITokensUsed, int AITokensUsed,
string CurrentPlan,
Guid TenantId, Guid TenantId,
SubscriptionPlanDto Plan,
int AverageQuizScore, int AverageQuizScore,
string LastReadBookTitle); LastReadBookDto? LastReadBook)
{
// Helper properties for UI compatibility
public string CurrentPlan => Plan?.Name ?? "Standard";
public int AITokenLimit => Plan?.AITokenLimit ?? 1000;
public string LastReadBookTitle => LastReadBook?.Title ?? "Brak aktywności";
}
public class IdentityService : IIdentityService public class IdentityService : IIdentityService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly INativeStorageService _storageService; private readonly INativeStorageService _storageService;
private readonly NexusAuthenticationStateProvider _authStateProvider; private readonly AuthenticationStateProvider? _authStateProvider;
private const string TokenKey = "nexus_auth_token"; private const string TokenKey = "nexus_auth_token";
private const string RefreshTokenKey = "nexus_refresh_token"; private const string RefreshTokenKey = "nexus_refresh_token";
private Task<UserProfile?>? _profileTask;
private UserProfile? _cachedProfile;
public IdentityService( public IdentityService(
HttpClient httpClient, HttpClient httpClient,
INativeStorageService storageService, INativeStorageService storageService,
NexusAuthenticationStateProvider authStateProvider) AuthenticationStateProvider? authStateProvider = null)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_storageService = storageService; _storageService = storageService;
@@ -47,11 +56,21 @@ public class IdentityService : IIdentityService
public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false) public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false)
{ {
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password }); var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password });
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var result = await response.Content.ReadFromJsonAsync<LoginResponse>(); _cachedProfile = null; // Clear cache to force fresh fetch
LoginResponse? result = null;
try
{
result = await response.Content.ReadFromJsonAsync<LoginResponse>();
}
catch (System.Text.Json.JsonException)
{
// Expected if useCookies=true and body is empty
}
if (result != null && !string.IsNullOrEmpty(result.AccessToken)) if (result != null && !string.IsNullOrEmpty(result.AccessToken))
{ {
await _storageService.SaveSecureString(TokenKey, result.AccessToken); await _storageService.SaveSecureString(TokenKey, result.AccessToken);
@@ -59,22 +78,25 @@ public class IdentityService : IIdentityService
{ {
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken); await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
} }
}
// Option A: Fetch profile to get claims // Always try to fetch profile after successful login (either via token or cookie)
var profile = await GetProfileAsync(); var profile = await GetProfileAsync();
if (profile != null) if (profile != null)
{ {
await _storageService.SaveSecureString("nexus_user_email", profile.Email); await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString()); await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString()); (_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
} return true;
else }
{
// Fallback if profile fetch fails
_authStateProvider.NotifyUserAuthentication(email, "unknown");
}
// If we have a successful status code but can't get the profile,
// we might still be logged in via cookie.
// We should try to notify with whatever info we have.
if (response.IsSuccessStatusCode)
{
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
return true; return true;
} }
} }
@@ -84,23 +106,81 @@ public class IdentityService : IIdentityService
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_storageService.RemoveSecure(TokenKey); _cachedProfile = null;
_storageService.RemoveSecure(RefreshTokenKey); if (System.OperatingSystem.IsBrowser())
_storageService.RemoveSecure("nexus_user_email"); {
_storageService.RemoveSecure("nexus_user_tenant"); await _storageService.SaveSecureString(TokenKey, "");
_authStateProvider.NotifyUserLogout(); await _storageService.SaveSecureString(RefreshTokenKey, "");
await _storageService.SaveSecureString("nexus_user_email", "");
await _storageService.SaveSecureString("nexus_user_tenant", "");
}
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserLogout();
} }
public async Task<UserProfile?> GetProfileAsync() public async Task<UserProfile?> GetProfileAsync()
{ {
if (_cachedProfile != null)
{
return _cachedProfile;
}
if (_profileTask != null)
{
return await _profileTask;
}
_profileTask = GetProfileInternalAsync();
return await _profileTask;
}
private DateTime _lastFetchAttempt = DateTime.MinValue;
private async Task<UserProfile?> GetProfileInternalAsync()
{
if (!System.OperatingSystem.IsBrowser())
{
return null;
}
if (DateTime.UtcNow - _lastFetchAttempt < TimeSpan.FromSeconds(5))
{
return null;
}
_lastFetchAttempt = DateTime.UtcNow;
try try
{ {
return await _httpClient.GetFromJsonAsync<UserProfile>("identity/profile"); var response = await _httpClient.GetAsync("identity/profile");
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
await LogoutAsync();
return null;
}
if (response.IsSuccessStatusCode)
{
var profile = await response.Content.ReadFromJsonAsync<UserProfile>();
if (profile != null)
{
_cachedProfile = profile;
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
}
return profile;
}
return null;
} }
catch catch
{ {
return null; return null;
} }
finally
{
_profileTask = null;
}
} }
public async Task<bool> RefreshTokenAsync() public async Task<bool> RefreshTokenAsync()
@@ -128,7 +208,7 @@ public class IdentityService : IIdentityService
{ {
await _storageService.SaveSecureString("nexus_user_email", profile.Email); await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString()); await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString()); (_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
} }
return true; return true;
@@ -15,37 +15,44 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
_storageService = storageService; _storageService = storageService;
} }
private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
try try
{ {
if (_cachedState != null) return _cachedState;
var tokenResult = await _storageService.GetSecureString(TokenKey); var tokenResult = await _storageService.GetSecureString(TokenKey);
var token = tokenResult.IsSuccess ? tokenResult.Value : null; var token = tokenResult.IsSuccess ? tokenResult.Value : null;
if (string.IsNullOrWhiteSpace(token)) // 1. Try Token-based auth
if (!string.IsNullOrWhiteSpace(token))
{ {
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); var emailResult = await _storageService.GetSecureString("nexus_user_email");
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
{
_cachedState = CreateState(emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer");
return _cachedState;
}
} }
// For opaque tokens, we read the user info that was stored during login // 2. Try Cookie-based auth indicators
var emailResult = await _storageService.GetSecureString("nexus_user_email"); var storedEmailResult = await _storageService.GetSecureString("nexus_user_email");
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant"); if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
var claims = new List<Claim>();
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
{ {
claims.Add(new Claim(ClaimTypes.Name, emailResult.Value)); var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
claims.Add(new Claim(ClaimTypes.Email, emailResult.Value)); _cachedState = CreateState(storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth");
} return _cachedState;
if (tenantIdResult.IsSuccess && !string.IsNullOrEmpty(tenantIdResult.Value))
{
claims.Add(new Claim("TenantId", tenantIdResult.Value));
} }
var identity = new ClaimsIdentity(claims, "OpaqueBearer"); // 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login).
var user = new ClaimsPrincipal(identity); // We should return anonymous for now but trigger a background check if we're in WASM.
// Wait! In WASM, the first GetAuthenticationStateAsync is awaited.
return new AuthenticationState(user); // We can do a quick check here if it's the first time.
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
} }
catch (Exception) catch (Exception)
{ {
@@ -53,7 +60,7 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
} }
} }
public void NotifyUserAuthentication(string email, string tenantId) private AuthenticationState CreateState(string email, string tenantId, string authType)
{ {
var claims = new List<Claim> var claims = new List<Claim>
{ {
@@ -61,17 +68,20 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
new Claim(ClaimTypes.Email, email), new Claim(ClaimTypes.Email, email),
new Claim("TenantId", tenantId) new Claim("TenantId", tenantId)
}; };
var identity = new ClaimsIdentity(claims, authType);
return new AuthenticationState(new ClaimsPrincipal(identity));
}
var identity = new ClaimsIdentity(claims, "OpaqueBearer"); public void NotifyUserAuthentication(string email, string tenantId)
var user = new ClaimsPrincipal(identity); {
var authState = Task.FromResult(new AuthenticationState(user)); _cachedState = CreateState(email, tenantId, "OpaqueBearer");
NotifyAuthenticationStateChanged(authState); NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
} }
public void NotifyUserLogout() public void NotifyUserLogout()
{ {
_cachedState = null;
var guest = new ClaimsPrincipal(new ClaimsIdentity()); var guest = new ClaimsPrincipal(new ClaimsIdentity());
var authState = Task.FromResult(new AuthenticationState(guest)); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
NotifyAuthenticationStateChanged(authState);
} }
} }
@@ -1,4 +1,5 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Client.Handlers; namespace NexusReader.Web.Client.Handlers;
@@ -15,6 +16,9 @@ public class AuthenticationHeaderHandler : DelegatingHandler
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
// Ensure cookies are sent (needed for InteractiveAuto SSR synchronization)
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
var tokenResult = await _storageService.GetSecureString(TokenKey); var tokenResult = await _storageService.GetSecureString(TokenKey);
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
+16 -5
View File
@@ -53,8 +53,6 @@ builder.Services.AddHttpClient("NexusAPI", client =>
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>(); builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication(); builder.Services.AddApplication();
@@ -93,14 +91,24 @@ builder.Services.AddIdentityApiEndpoints<NexusUser>()
builder.Services.ConfigureApplicationCookie(options => builder.Services.ConfigureApplicationCookie(options =>
{ {
options.LoginPath = "/account/login"; options.LoginPath = "/account/login";
options.LogoutPath = "/account/logout";
options.AccessDeniedPath = "/account/access-denied";
options.Cookie.Name = "NexusReader.Auth";
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromDays(30); options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true; options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context => options.Events.OnRedirectToLogin = context =>
{ {
if (context.Request.Path.StartsWithSegments("/api")) var isApiRequest = context.Request.Path.StartsWithSegments("/api") ||
context.Request.Path.StartsWithSegments("/identity") ||
context.Request.Headers["Accept"].ToString().Contains("application/json");
if (isApiRequest)
{ {
context.Response.StatusCode = 401; context.Response.StatusCode = StatusCodes.Status401Unauthorized;
} }
else else
{ {
@@ -434,6 +442,7 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUs
{ {
Email = u.Email ?? string.Empty, Email = u.Email ?? string.Empty,
AITokensUsed = u.AITokensUsed, AITokensUsed = u.AITokensUsed,
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{ {
Id = u.SubscriptionPlan.Id, Id = u.SubscriptionPlan.Id,
@@ -441,7 +450,9 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUs
AITokenLimit = u.SubscriptionPlan.AITokenLimit, AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(), } : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any() ? (int)u.QuizResults.Average(q => q.Percentage) : 0, 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 LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{ {
Id = e.Id, Id = e.Id,