4 Commits

Author SHA1 Message Date
mjasin 39d9423d67 fix(ui): implement ServerIdentityService to resolve SSR profile fetch issues
- Added ServerIdentityService to handle profile requests directly from DB on server
- Updated Program.cs to use ServerIdentityService in the Web project
- Cleaned up diagnostic logs across the solution
- Finalized Dashboard glass panel animations
2026-05-10 09:49:44 +02:00
mjasin ab605dff42 chore: add diagnostic console logging to identity service and UI components 2026-05-10 09:39:26 +02:00
mjasin 5fdc89dbf3 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
2026-05-10 09:28:40 +02:00
mjasin 34794db209 feat(ui/graph): Knowledge Graph Refinement and Sidebar Hierarchy (#25)
This PR addresses several UI/UX and architectural refinements for the Knowledge Graph and Intelligence Sidebar.

### Key Changes:
- **Knowledge Graph (#21, #22)**:
  - Implemented \"pill-shaped\" nodes with dynamic label truncation and SVG tooltips.
  - Added bound-constrained simulation to keep nodes within the viewport.
  - Integrated `ResizeObserver` for dynamic layout handling.
  - Implemented Zoom-to-Fit functionality.
  - Enforced unique concept IDs in AI prompts and hardened JS logic to prevent multi-selection bugs.
- **Intelligence Sidebar (#23)**:
  - Improved visual depth with a radial gradient background for the graph.
  - Increased sidebar divider contrast for better layering.
  - Transformed graph controls into a floating glassmorphism panel.
  - Relocated the \"Logout\" action to the toolbar bottom and rebranded it as \"Exit\".

Fixes #21
Fixes #22
Fixes #23

Reviewed-on: #25
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-09 09:36:23 +00:00
33 changed files with 1634 additions and 392 deletions
+3
View File
@@ -44,3 +44,6 @@ description: Design System & Component rules for Blazor
- **Interactive Flow:** - **Interactive Flow:**
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations. - AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states. - Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following interaction signature:
- `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`
- `:hover` state must include: `transform: translateY(-4px)`, increased background opacity, and a subtle `--nexus-neon` border highlight (e.g., `rgba(0, 255, 153, 0.2)`).
@@ -3,4 +3,4 @@ using MediatR;
namespace NexusReader.Application.Commands.Sync; namespace NexusReader.Application.Commands.Sync;
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>; public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest<Result>;
@@ -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.
@@ -38,9 +38,18 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
// Broadcast to other devices // Broadcast to other devices
await _hubContext.Clients var group = _hubContext.Clients.Group($"User_{request.UserId}");
.Group($"User_{request.UserId}")
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken); if (!string.IsNullOrEmpty(request.ExcludedConnectionId))
{
await _hubContext.Clients
.GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
else
{
await group.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
return Result.Ok(); return Result.Ok();
} }
@@ -20,7 +20,7 @@ public class SyncHub : Hub
var userId = Context.UserIdentifier; var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
{ {
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId)); await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId));
} }
} }
@@ -4,6 +4,8 @@ public static class PromptRegistry
{ {
public const string KnowledgeExtractionSystemPrompt = public const string KnowledgeExtractionSystemPrompt =
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " + "You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " +
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " + "CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
"Schema: { " + "Schema: { " +
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " + "\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
@@ -13,11 +15,15 @@ public static class PromptRegistry
public const string GraphExtractionPrompt = public const string GraphExtractionPrompt =
"You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " + "You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " +
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " + "CRITICAL: Restrict 'label' to a maximum of 3 words. " +
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points and their relationships. " +
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. Use these IDs ONLY for nodes representing the blocks. " +
"CRITICAL: All other extracted 'concept' nodes MUST have unique, slug-style IDs based on their labels (e.g., 'dependency-injection'). " +
"Include a 'current' node representing the block content itself if applicable. " + "Include a 'current' node representing the block content itself if applicable. " +
"CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " + "CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " +
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
public const string SummaryAndQuizPrompt = public const string SummaryAndQuizPrompt =
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " + "You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }"; "Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
+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

@@ -129,7 +129,7 @@
{ {
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase)) if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
{ {
QuizState.RequestQuiz(ContextBlockId); await QuizState.RequestQuiz(ContextBlockId);
} }
if (OnActionTriggered.HasDelegate) if (OnActionTriggered.HasDelegate)
@@ -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,10 +35,10 @@
@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="Logout"> <button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
<NexusIcon Name="log-out" Size="20" /> <NexusIcon Name="log-out" Size="20" />
</button> </button>
</div> </div>
@@ -75,3 +75,22 @@
color: #ff4d4d; color: #ff4d4d;
background: rgba(255, 77, 77, 0.1); background: rgba(255, 77, 77, 0.1);
} }
.toolbar-item.logout-item {
margin-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 1.5rem;
height: auto;
width: 100%;
display: flex;
justify-content: center;
border-radius: 0;
color: #444;
}
.toolbar-item.logout-item:hover {
color: #ff4d4d;
background: none;
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.4));
}
@@ -12,28 +12,33 @@
.graph-controls { .graph-controls {
position: absolute; position: absolute;
bottom: 1rem; bottom: 1.5rem;
right: 1.5rem; right: 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.5rem; gap: 0.25rem;
background: rgba(20, 20, 20, 0.4);
backdrop-filter: blur(12px);
padding: 0.35rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10; z-index: 10;
} }
.zoom-btn { .zoom-btn {
width: 28px; width: 32px;
height: 28px; height: 32px;
background: rgba(18, 18, 18, 0.8); background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(4px); border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px;
border-radius: 4px; color: #aaa;
color: #888; font-size: 1.1rem;
font-size: 1rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s ease; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
} }
.zoom-btn:hover { .zoom-btn:hover {
@@ -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.
@@ -33,7 +33,7 @@ main {
height: 100%; height: 100%;
background: #0d0d0d; background: #0d0d0d;
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3); box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
border-left: 1px solid rgba(255, 255, 255, 0.05); border-left: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden; overflow: hidden;
z-index: 10; z-index: 10;
} }
@@ -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,366 @@
.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;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-panel:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 255, 153, 0.2);
transform: translateY(-4px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
/* 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;
@@ -79,7 +79,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global") public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{ {
_quizService.SetHydrating(true); await _quizService.SetHydrating(true);
LogRequestingSummary(tenantId); LogRequestingSummary(tenantId);
try try
{ {
@@ -91,7 +91,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex)) .Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
.ToList(); .ToList();
_quizService.SetQuiz(null, new QuizDto(quizQuestions)); await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
await _platformService.VibrateSuccessAsync(); await _platformService.VibrateSuccessAsync();
return packet; return packet;
} }
@@ -104,7 +104,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
finally finally
{ {
_quizService.SetHydrating(false); await _quizService.SetHydrating(false);
} }
return null; return null;
} }
@@ -112,7 +112,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task ClearAsync() public async Task ClearAsync()
{ {
await _graphService.Clear(); await _graphService.Clear();
_quizService.SetQuiz(null, null); await _quizService.SetQuiz(null, null);
} }
public void Dispose() public void Dispose()
@@ -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,10 +1,13 @@
import * as d3 from 'https://esm.sh/d3@7'; import * as d3 from 'https://esm.sh/d3@7';
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
let simulation; let simulation;
let zoomBehavior; let zoomBehavior;
let svgElement; let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler; let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
export function mount(containerId, data, dotNetHelper) { export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
@@ -18,7 +21,8 @@ export function mount(containerId, data, dotNetHelper) {
svgElement = d3.select(container).append("svg") svgElement = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height]) .attr("viewBox", [0, 0, width, height])
.attr("width", "100%") .attr("width", "100%")
.attr("height", "100%"); .attr("height", "100%")
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
// Radial gradient for Nebula effect // Radial gradient for Nebula effect
const defs = svgElement.append("defs"); const defs = svgElement.append("defs");
@@ -66,14 +70,19 @@ export function mount(containerId, data, dotNetHelper) {
svgElement.call(zoomBehavior).on("wheel.zoom", null); svgElement.call(zoomBehavior).on("wheel.zoom", null);
resizeHandler = () => handleResize(containerId); // Use ResizeObserver for more reliable container size tracking
window.addEventListener('resize', resizeHandler); resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
handleResize(containerId);
}
});
resizeObserver.observe(container);
simulation = d3.forceSimulation() simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120)) .force("link", d3.forceLink().id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400)) .force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2)) .force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(50)); .force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
simulation.on("tick", () => { simulation.on("tick", () => {
if (link) { if (link) {
@@ -86,7 +95,14 @@ export function mount(containerId, data, dotNetHelper) {
} }
if (node) { if (node) {
node.attr("transform", d => `translate(${d.x},${d.y})`); node.attr("transform", d => {
// Keep within bounds with padding
const pillWidth = getPillWidth(d);
const halfWidth = pillWidth / 2;
d.x = Math.max(halfWidth + 20, Math.min(width - halfWidth - 20, d.x));
d.y = Math.max(35, Math.min(height - 35, d.y));
return `translate(${d.x},${d.y})`;
});
} }
if (badge && badge.style("display") !== "none") { if (badge && badge.style("display") !== "none") {
@@ -168,11 +184,11 @@ export function updateData(data) {
g.append("rect") g.append("rect")
.attr("class", "node-pill") .attr("class", "node-pill")
.attr("x", d => -(d.label.length * 4 + 10)) .attr("x", d => -getPillWidth(d) / 2)
.attr("y", -12) .attr("y", -15)
.attr("width", d => d.label.length * 8 + 20) .attr("width", d => getPillWidth(d))
.attr("height", 24) .attr("height", 30)
.attr("rx", 12) .attr("rx", 15)
.attr("fill", "rgba(20, 20, 20, 0.9)") .attr("fill", "rgba(20, 20, 20, 0.9)")
.attr("stroke", d => { .attr("stroke", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)'; if (d.type === 'Definition') return 'var(--nexus-accent)';
@@ -182,12 +198,15 @@ export function updateData(data) {
.attr("stroke-width", 1); .attr("stroke-width", 1);
g.append("text") g.append("text")
.text(d => d.label) .text(d => getDisplayLabel(d))
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.attr("y", 4) .attr("y", 5)
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc') .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
.attr("font-size", "0.8rem"); .attr("font-size", "0.8rem");
g.append("title")
.text(d => d.label);
g.transition().duration(500).style("opacity", 1); g.transition().duration(500).style("opacity", 1);
return g; return g;
@@ -199,6 +218,9 @@ export function updateData(data) {
simulation.nodes(data.nodes); simulation.nodes(data.nodes);
simulation.force("link").links(data.links); simulation.force("link").links(data.links);
simulation.alpha(0.5).restart(); simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
setTimeout(zoomToFit, 100);
} }
function drag(simulation) { function drag(simulation) {
@@ -225,6 +247,7 @@ function drag(simulation) {
export function setActiveNode(nodeId) { export function setActiveNode(nodeId) {
if (!svgElement || !node) return; if (!svgElement || !node) return;
// Safety check: ensure we only target the first occurrence if IDs are duplicated
const targetNode = node.filter(d => d.id === nodeId); const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) { if (targetNode.empty()) {
dimNodes(null); dimNodes(null);
@@ -232,20 +255,21 @@ export function setActiveNode(nodeId) {
return; return;
} }
const d = targetNode.datum(); const firstMatch = targetNode.filter((d, i) => i === 0);
const d = firstMatch.datum();
// Reset all active classes // Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false); rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
targetNode.select(".node-pill").classed("nexus-node-active", true); firstMatch.select(".node-pill").classed("nexus-node-active", true);
// Position badge // Position badge
badge.style("display", "block").datum(d); badge.style("display", "block").datum(d);
badge.attr("transform", `translate(${d.x},${d.y})`); badge.attr("transform", `translate(${d.x},${d.y})`);
// Dim others // Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId); dimNodes(nodeId);
// Smooth transition // Smooth transition to the first matching node
svgElement.transition().duration(1000).call( svgElement.transition().duration(1000).call(
zoomBehavior.transform, zoomBehavior.transform,
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y) d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
@@ -274,8 +298,8 @@ export function unmount(containerId) {
if (simulation) { if (simulation) {
simulation.stop(); simulation.stop();
} }
if (resizeHandler) { if (resizeObserver) {
window.removeEventListener('resize', resizeHandler); resizeObserver.disconnect();
} }
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (container) { if (container) {
@@ -321,9 +345,43 @@ export function zoomOut() {
} }
export function zoomReset() { export function zoomReset() {
if (svgElement && zoomBehavior) { zoomToFit();
svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity); }
}
export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
// Get the actual bounding box of the nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
node.each(d => {
const pw = getPillWidth(d) / 2;
minX = Math.min(minX, d.x - pw);
maxX = Math.max(maxX, d.x + pw);
minY = Math.min(minY, d.y - 15);
maxY = Math.max(maxY, d.y + 15);
});
if (minX === Infinity) return;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const padding = 60;
const scale = Math.min(
(width - padding) / graphWidth,
(height - padding) / graphHeight,
1.2 // Max scale
);
svgElement.transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-midX, -midY)
);
} }
export function clear() { export function clear() {
@@ -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))
+18 -6
View File
@@ -52,9 +52,8 @@ 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.AddHttpContextAccessor();
builder.Services.AddScoped<NexusAuthenticationStateProvider>(); builder.Services.AddScoped<IIdentityService, NexusReader.Web.New.Services.ServerIdentityService>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication(); builder.Services.AddApplication();
@@ -93,14 +92,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 +443,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 +451,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,
@@ -0,0 +1,74 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.New.Services;
public class ServerIdentityService : IIdentityService
{
private readonly UserManager<NexusUser> _userManager;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public ServerIdentityService(
UserManager<NexusUser> userManager,
IHttpContextAccessor httpContextAccessor,
IDbContextFactory<AppDbContext> dbContextFactory)
{
_userManager = userManager;
_httpContextAccessor = httpContextAccessor;
_dbContextFactory = dbContextFactory;
}
public Task<bool> LoginAsync(string email, string password, bool rememberMe = false)
=> throw new NotSupportedException("Use standard Identity endpoints for login on server.");
public Task LogoutAsync()
=> throw new NotSupportedException("Use standard Identity endpoints for logout on server.");
public Task<bool> RegisterAsync(string email, string password)
=> throw new NotSupportedException("Use standard Identity endpoints for registration on server.");
public Task<bool> RefreshTokenAsync() => Task.FromResult(true);
public async Task<UserProfile?> GetProfileAsync()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user == null || !user.Identity?.IsAuthenticated == true) return null;
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return null;
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var profile = await dbContext.Users
.Where(u => u.Id == userId)
.Select(u => new UserProfile(
u.Email ?? string.Empty,
u.AITokensUsed,
u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
u.SubscriptionPlan != null ? new SubscriptionPlanDto
{
Id = u.SubscriptionPlan.Id,
Name = u.SubscriptionPlan.PlanName,
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(),
u.QuizResults.Any(q => q.TotalQuestions > 0)
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
: 0,
u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{
Id = e.Id,
Title = e.Title
}).FirstOrDefault()
))
.FirstOrDefaultAsync();
return profile;
}
}