6 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
mjasin 9e77aee231 Refactor Intelligence Toolbar (#14) and fix auth regression (#24)
This PR resolves the authentication regression issue where users encountered "Unauthorized" errors after logging out and back in. This regression was identified during the refactoring of the Intelligence Toolbar.

Fixes #14

### Changes:
- **WASM Client**: Added `AuthenticationHeaderHandler` to automatically attach Bearer tokens to `HttpClient` requests.
- **Server**: Configured Cookie authentication to return `401 Unauthorized` for `/api` requests instead of redirecting to the login page.
- **Project Configuration**: Added `Microsoft.Extensions.Http` to the WASM client project to support `IHttpClientFactory` and message handlers.

Verified with local build.

Reviewed-on: #24
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-08 18:50:15 +00:00
mjasin 55cc3ae10d feat(ui/arch): Optimize Graph Dynamics, Immersive Reader, and Core Stability (#19)
This PR introduces a major optimization of graph dynamics, immersive reading experience, and architectural stabilization.

### 🚀 Key Improvements

- **Knowledge Graph (Fix #16)**:
  - Implemented smooth D3.js transitions using the General Update Pattern.
  - Added "Neon Flash" entry animations and dynamic node dimming for better focus.
- **Immersive Reader (Fix #12)**:
  - Standardized centered layout (`max-width: 800px`) with **Merriweather** typography.
  - Optimized line-height and letter-spacing for premium readability.
- **Technical Code Blocks (Fix #20)**:
  - High-contrast dark containers for code snippets.
  - **JetBrains Mono** integration and neon-accented scrollbars.
- **Architectural Stabilization**:
  - Enforced a strict **'no async void'** policy in UI services using `Func<Task>`.
  - Resolved WASM runtime DI errors by implementing dummy service proxies for server-side dependencies.
  - Replaced generic 'Not Found' message with a branded Nexus preloader.

Fixes #7, Fixes #12, Fixes #16, Fixes #20.

Reviewed-on: #19
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-08 18:16:09 +00:00
60 changed files with 2142 additions and 578 deletions
@@ -29,9 +29,12 @@ This skill defines the architectural guardrails for the NexusReader project to e
- Use `FluentResults` (`Result<T>`) for all Application services and handlers. - Use `FluentResults` (`Result<T>`) for all Application services and handlers.
- Avoid throwing exceptions for expected business failures; use `Result.Fail()`. - Avoid throwing exceptions for expected business failures; use `Result.Fail()`.
### 4. MediatR Patterns
- **Queries**: Read-only operations. Should return `Result<T>`. Use `AsNoTracking()` in EF Core.
- **Commands**: State-changing operations. Should return `Result` or `Result<T>`. - **Commands**: State-changing operations. Should return `Result` or `Result<T>`.
+
+### 5. Async Operations (Zero Tolerance for `async void`)
+- All asynchronous operations MUST return `Task` or `ValueTask`.
+- Event handlers MUST use `Func<Task>` or async-compatible patterns.
+- UI components MUST await all service calls and use `InvokeAsync(StateHasChanged)` for state updates within async contexts.
## Audit Scripts ## Audit Scripts
- [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports. - [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports.
+3
View File
@@ -8,4 +8,7 @@ description: D3.js standards for Knowledge Graph
- **JS Interop:** Use ES6 modules and `IJSObjectReference`. - **JS Interop:** Use ES6 modules and `IJSObjectReference`.
- **Responsiveness:** SVG must use `viewBox` for fluid portrait scaling. - **Responsiveness:** SVG must use `viewBox` for fluid portrait scaling.
- **Visuals:** Use CSS variables (`--nexus-neon`) for node styling. - **Visuals:** Use CSS variables (`--nexus-neon`) for node styling.
- **Transitions:** Enforce smooth 500ms transitions using the D3.js General Update Pattern (`.join()`).
- **Animations:** Implement "Neon Flash" entry animations for newly discovered knowledge nodes.
- **Contextual Highlight:** Support node/link dimming to emphasize the current reading context.
- **Events:** JS emits events (like `nodeClicked`) caught by Blazor via `DotNetObjectReference`. - **Events:** JS emits events (like `nodeClicked`) caught by Blazor via `DotNetObjectReference`.
+9 -1
View File
@@ -22,7 +22,7 @@ description: Design System & Component rules for Blazor
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`). - Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
- **Typography:** - **Typography:**
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels. - UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
- Reading Content: `Merriweather` (Serif) for books and articles to ensure high readability. - Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
- **Effects:** - **Effects:**
- Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`). - Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`).
- Glassmorphism for overlays and modals. - Glassmorphism for overlays and modals.
@@ -30,6 +30,11 @@ description: Design System & Component rules for Blazor
- **Adaptive Layouts:** - **Adaptive Layouts:**
- Support `.platform-mobile` and `.platform-desktop` context classes. - Support `.platform-mobile` and `.platform-desktop` context classes.
- Handle safe-area insets (`--safe-area-inset-*`) for mobile devices. - Handle safe-area insets (`--safe-area-inset-*`) for mobile devices.
- **Immersive Reader (Zen Mode):**
- Centered content flow: `max-width: 800px`, `margin: 0 auto`.
- Paper-white background: `#F9F9F9` for light mode reader canvas.
- Dedicated Scrollbars: Custom styled, thin scrollbars with `--nexus-neon` accents.
- Reachability: Large `padding-bottom` (e.g., `15rem`) to ensure comfortable reading of end-of-page content.
- **Accessibility (A11y):** - **Accessibility (A11y):**
- Touch Targets: Min `44x44px` on mobile (enforced via CSS variables). - Touch Targets: Min `44x44px` on mobile (enforced via CSS variables).
@@ -39,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.
@@ -8,6 +8,7 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services) public static IServiceCollection AddApplication(this IServiceCollection services)
{ {
services.AddMapsterConfiguration(); services.AddMapsterConfiguration();
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services; return services;
} }
@@ -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
var group = _hubContext.Clients.Group($"User_{request.UserId}");
if (!string.IsNullOrEmpty(request.ExcludedConnectionId))
{
await _hubContext.Clients await _hubContext.Clients
.Group($"User_{request.UserId}") .GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken); .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));
} }
} }
@@ -46,34 +46,35 @@ public class EpubService : IEpubService
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist."); return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
} }
EpubBook book; using var bookRef = await EpubReader.OpenBookAsync(fullPath);
try var readingOrder = bookRef.GetReadingOrder();
{
book = await EpubReader.ReadBookAsync(fullPath);
}
catch (Exception ex)
{
return Result.Fail(new Error($"Failed to parse EPUB file. It might be corrupted or in use. Path: {fullPath}").CausedBy(ex));
}
var blocks = new List<ContentBlock>();
int totalWordCount = 0;
int blockCounter = 0;
if (book.ReadingOrder == null || !book.ReadingOrder.Any()) if (readingOrder == null || !readingOrder.Any())
{ {
return Result.Fail("The EPUB has no readable content files in ReadingOrder."); return Result.Fail("The EPUB has no readable content files in ReadingOrder.");
} }
// Ensure index is within bounds // Ensure index is within bounds
if (chapterIndex < 0 || chapterIndex >= book.ReadingOrder.Count) if (chapterIndex < 0 || chapterIndex >= readingOrder.Count)
{ {
chapterIndex = 0; // Default to first chapter chapterIndex = 0; // Default to first chapter
} }
var chapter = book.ReadingOrder[chapterIndex]; var chapterRef = readingOrder[chapterIndex];
var chapterTitle = chapter.FilePath ?? $"Chapter {chapterIndex + 1}";
var paragraphs = ExtractParagraphs(chapter.Content); // Try to find a better title from navigation (TOC)
var navigation = bookRef.GetNavigation();
var chapterTitle = FindTitleInNavigation(navigation, chapterRef.FilePath)
?? Path.GetFileNameWithoutExtension(chapterRef.FilePath)
?? $"Chapter {chapterIndex + 1}";
var chapterContent = await chapterRef.ReadContentAsTextAsync();
var blocks = new List<ContentBlock>();
int totalWordCount = 0;
int blockCounter = 0;
var paragraphs = ExtractParagraphs(chapterContent);
foreach (var p in paragraphs) foreach (var p in paragraphs)
{ {
var sanitizedContent = SanitizeParagraph(p); var sanitizedContent = SanitizeParagraph(p);
@@ -99,7 +100,7 @@ public class EpubService : IEpubService
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}")); blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
} }
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, book.ReadingOrder.Count, chapterTitle)); return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -162,4 +163,25 @@ public class EpubService : IEpubService
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" } new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" }
); );
} }
private string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
{
if (string.IsNullOrEmpty(filePath)) return null;
var fileName = Path.GetFileName(filePath);
foreach (var item in navigation)
{
// Match by full path or just filename as fallback
if (item.Link?.ContentFilePath == filePath || item.Link?.ContentFilePath == fileName)
return item.Title;
if (item.NestedItems != null && item.NestedItems.Any())
{
var childTitle = FindTitleInNavigation(item.NestedItems, filePath);
if (childTitle != null) return childTitle;
}
}
return null;
}
} }
@@ -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" />
@@ -40,6 +77,15 @@
case "eye-off": case "eye-off":
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" /> <path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
break; break;
case "arrow-left":
<path d="M19 12H5M12 19l-7-7 7-7" />
break;
case "arrow-right":
<path d="M5 12h14M12 5l7 7-7 7" />
break;
case "log-out":
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
default: default:
<!-- Fallback circle --> <!-- Fallback circle -->
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -42,6 +42,7 @@
/// <summary>Fallback static dialogue shown when no live AI content is available.</summary> /// <summary>Fallback static dialogue shown when no live AI content is available.</summary>
[Parameter] public string Dialogue { get; set; } = string.Empty; [Parameter] public string Dialogue { get; set; } = string.Empty;
[Parameter] public List<string> Actions { get; set; } = new(); [Parameter] public List<string> Actions { get; set; } = new();
[Parameter] public string FullPageContent { get; set; } = string.Empty;
[Parameter] public EventCallback<string> OnActionTriggered { get; set; } [Parameter] public EventCallback<string> OnActionTriggered { get; set; }
private string _displayedText = string.Empty; private string _displayedText = string.Empty;
@@ -76,8 +77,11 @@
try try
{ {
_packet = await Coordinator.RequestSummaryAndQuizAsync( var contentToAnalyze = !string.IsNullOrWhiteSpace(FullPageContent)
$"[ID: {ContextBlockId}]\n{Dialogue}"); ? FullPageContent
: $"[ID: {ContextBlockId}]\n{Dialogue}";
_packet = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze);
var summary = _packet?.Summary; var summary = _packet?.Summary;
@@ -125,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)
@@ -2,11 +2,13 @@
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@inject IFocusModeService FocusMode @inject IFocusModeService FocusMode
@inject IKnowledgeService KnowledgeService @inject IKnowledgeService KnowledgeService
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<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" />
@@ -33,8 +35,11 @@
@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 class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
<NexusIcon Name="log-out" Size="20" />
</button> </button>
</div> </div>
</aside> </aside>
@@ -42,7 +47,7 @@
@code { @code {
protected override void OnInitialized() protected override void OnInitialized()
{ {
FocusMode.OnFocusModeChanged += StateHasChanged; FocusMode.OnFocusModeChanged += HandleUpdate;
} }
private async Task HandleClearCache() private async Task HandleClearCache()
@@ -56,8 +61,16 @@
} }
} }
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/", true);
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
FocusMode.OnFocusModeChanged -= StateHasChanged; FocusMode.OnFocusModeChanged -= HandleUpdate;
} }
} }
@@ -1,8 +1,8 @@
.intelligence-toolbar { .intelligence-toolbar {
width: 50px; width: 50px;
height: 100%; height: 100%;
background: #080808; background: #0D0D0D;
border-right: 1px solid rgba(255, 255, 255, 0.03); border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -10,6 +10,7 @@
align-items: center; align-items: center;
z-index: 20; z-index: 20;
box-shadow: inset -2px 0 10px rgba(0,0,0,0.5); box-shadow: inset -2px 0 10px rgba(0,0,0,0.5);
backdrop-filter: blur(10px);
} }
@@ -22,7 +23,7 @@
.toolbar-item { .toolbar-item {
background: none; background: none;
border: none; border: none;
color: #444; color: #555;
cursor: pointer; cursor: pointer;
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -37,11 +38,15 @@
.toolbar-item:hover { .toolbar-item:hover {
color: var(--nexus-neon); color: var(--nexus-neon);
background: rgba(0, 255, 153, 0.05); background: rgba(0, 255, 153, 0.05);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
filter: drop-shadow(0 0 5px var(--nexus-neon));
} }
.toolbar-item.active { .toolbar-item.active {
color: var(--nexus-neon); color: var(--nexus-neon);
background: rgba(0, 255, 153, 0.08); background: rgba(0, 255, 153, 0.08);
box-shadow: 0 0 20px rgba(0, 255, 153, 0.25);
filter: drop-shadow(0 0 8px var(--nexus-neon));
} }
.toolbar-item.active::after { .toolbar-item.active::after {
@@ -70,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));
}
@@ -55,12 +55,14 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
QuizService.OnQuizUpdated += () => InvokeAsync(StateHasChanged); QuizService.OnQuizUpdated += HandleUpdate;
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
QuizService.OnQuizUpdated -= StateHasChanged; QuizService.OnQuizUpdated -= HandleUpdate;
} }
private async Task SelectOptionAsync(QuizQuestionDto question, int index) private async Task SelectOptionAsync(QuizQuestionDto question, int index)
@@ -29,7 +29,7 @@
</div> </div>
<div class="ai-actions"> <div class="ai-actions">
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button> <button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
<button class="action-btn ghost" @onclick="Close">Zamknij</button> <button class="action-btn ghost" @onclick="CloseAsync">Zamknij</button>
</div> </div>
} }
else else
@@ -39,7 +39,7 @@
</div> </div>
<div class="ai-actions"> <div class="ai-actions">
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button> <button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
<button class="action-btn ghost" @onclick="Close">Pomiń</button> <button class="action-btn ghost" @onclick="CloseAsync">Pomiń</button>
</div> </div>
} }
</div> </div>
@@ -76,7 +76,11 @@
private async Task RequestSummary() private async Task RequestSummary()
{ {
IsLoading = true; IsLoading = true;
Packet = await Coordinator.RequestSummaryAndQuizAsync(SelectedText); var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: "";
Packet = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
IsLoading = false; IsLoading = false;
} }
@@ -85,12 +89,12 @@
IsLoading = true; IsLoading = true;
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent); await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
IsLoading = false; IsLoading = false;
Close(); await CloseAsync();
} }
private void Close() private async Task CloseAsync()
{ {
Packet = null; Packet = null;
InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!); await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
} }
} }
@@ -46,7 +46,7 @@
GraphService.OnLoadingChanged += HandleLoadingChange; GraphService.OnLoadingChanged += HandleLoadingChange;
} }
private async void HandleGraphUpdate() private async Task HandleGraphUpdate()
{ {
if (_module == null) return; if (_module == null) return;
@@ -62,13 +62,13 @@
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private async void HandleActiveNodeChange(string nodeId) private async Task HandleActiveNodeChange(string nodeId)
{ {
if (_module == null) return; if (_module == null) return;
await _module.InvokeVoidAsync("setActiveNode", nodeId); await _module.InvokeVoidAsync("setActiveNode", nodeId);
} }
private async void HandleLoadingChange(bool isLoading) private async Task HandleLoadingChange(bool isLoading)
{ {
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -81,7 +81,7 @@
if (GraphService.CurrentGraphData != null) if (GraphService.CurrentGraphData != null)
{ {
HandleGraphUpdate(); await HandleGraphUpdate();
} }
} }
} }
@@ -100,7 +100,7 @@
[JSInvokable] [JSInvokable]
public async Task OnNodeClicked(string nodeId) public async Task OnNodeClicked(string nodeId)
{ {
InteractionService.NotifyNodeSelected(nodeId); await InteractionService.NotifyNodeSelected(nodeId);
if (OnNodeSelected.HasDelegate) if (OnNodeSelected.HasDelegate)
{ {
@@ -109,7 +109,7 @@
} }
private async void HandleFocusSimulation() private async Task HandleFocusSimulation()
{ {
if (_module == null) return; if (_module == null) return;
try try
@@ -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 {
@@ -98,3 +103,13 @@
filter: drop-shadow(0 0 12px var(--nexus-neon)); filter: drop-shadow(0 0 12px var(--nexus-neon));
transition: all 0.3s ease; transition: all 0.3s ease;
} }
::deep @keyframes neon-flash {
0% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
50% { filter: brightness(3) drop-shadow(0 0 30px var(--nexus-neon)); }
100% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
}
::deep .neon-flash-node {
animation: neon-flash 0.8s ease-out;
}
@@ -52,15 +52,16 @@
private bool _isJsInitialized; private bool _isJsInitialized;
private ElementReference _containerRef; private ElementReference _containerRef;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
Coordinator.Clear(); await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += StateHasChanged; ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged; NavigationService.OnNavigationChanged += OnNavigationChanged;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested; InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested; InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected; InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived;
} }
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
@@ -113,60 +114,56 @@
} }
[JSInvokable] [JSInvokable]
public void HandleBlockReached(string blockId, string content) public async Task HandleBlockReached(string blockId, string content)
{ {
Coordinator.OnBlockReached(blockId, content); await Coordinator.OnBlockReachedAsync(blockId, content);
// Debounce sync update (simple version: every 5 seconds or on a timer) // Debounce sync update (simple version: every 5 seconds or on a timer)
_ = SyncService.UpdateProgressAsync(blockId); await SyncService.UpdateProgressAsync(blockId);
} }
private void HandleSyncProgressReceived(string blockId, DateTime timestamp) private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
{ {
// For now, let's just scroll to the node if it's in the current view, // For now, let's just scroll to the node if it's in the current view,
// or just log it. Usually, we should prompt the user. // or just log it. Usually, we should prompt the user.
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}"); Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
// Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
// but we can assume incoming syncs are from other active devices)
_ = InvokeAsync(async () => {
await ScrollToNodeAsync(blockId); await ScrollToNodeAsync(blockId);
StateHasChanged(); await InvokeAsync(StateHasChanged);
});
} }
[JSInvokable] [JSInvokable]
public void HandleTextSelected(string text, string blockId, SelectionCoordinates coords) public async Task HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
{ {
Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}"); Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}");
_selectedText = text; _selectedText = text;
_selectedBlockId = blockId; _selectedBlockId = blockId;
_selectionCoords = coords; _selectionCoords = coords;
StateHasChanged(); await InvokeAsync(StateHasChanged);
} }
[JSInvokable] [JSInvokable]
public void HandleSelectionCleared() public async Task HandleSelectionCleared()
{ {
_selectedText = string.Empty; _selectedText = string.Empty;
_selectionCoords = null; _selectionCoords = null;
StateHasChanged(); await InvokeAsync(StateHasChanged);
} }
private void HandleScrollRequested(string blockId) private async Task HandleScrollRequested(string blockId)
{ {
_ = ScrollToNodeAsync(blockId); await ScrollToNodeAsync(blockId);
} }
private async void HandleHighlightRequested(string blockId) private async Task HandleHighlightRequested(string blockId)
{ {
_highlightedBlockId = blockId; _highlightedBlockId = blockId;
StateHasChanged(); await InvokeAsync(StateHasChanged);
await Task.Delay(3000); // Highlight for 3 seconds await Task.Delay(3000); // Highlight for 3 seconds
if (_highlightedBlockId == blockId) if (_highlightedBlockId == blockId)
{ {
_highlightedBlockId = null; _highlightedBlockId = null;
StateHasChanged(); await InvokeAsync(StateHasChanged);
} }
} }
@@ -212,9 +209,11 @@
catch { } catch { }
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
ThemeService.OnThemeChanged -= StateHasChanged; ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged; NavigationService.OnNavigationChanged -= OnNavigationChanged;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested; InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
@@ -1,16 +1,47 @@
.reader-canvas { .reader-canvas {
max-width: 800px; width: 100%;
margin: 0 auto; height: 100%;
padding: 2rem 1rem; overflow-y: auto;
overflow-x: hidden;
padding: 2rem 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
/* Dedicated Scrollbar Styling */
scrollbar-width: thin;
scrollbar-color: rgba(0, 255, 153, 0.2) transparent;
}
.reader-canvas::-webkit-scrollbar {
width: 6px;
}
.reader-canvas::-webkit-scrollbar-track {
background: transparent;
}
.reader-canvas::-webkit-scrollbar-thumb {
background-color: rgba(0, 255, 153, 0.2);
border-radius: 20px;
border: 3px solid transparent;
}
.reader-canvas:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 255, 153, 0.5);
}
.reader-canvas.theme-light {
background-color: #F9F9F9; /* Paper-white requirement */
} }
.reader-flow-container { .reader-flow-container {
max-width: 800px;
margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
position: relative; position: relative;
padding: 0 1.5rem 15rem 1.5rem; /* Large padding-bottom for reachability */
} }
.block-wrapper { .block-wrapper {
@@ -20,6 +51,68 @@
border: 1px solid transparent; border: 1px solid transparent;
} }
/* Typographic refinement for TextSegmentBlock */
::deep .nexus-ebook {
font-family: 'Merriweather', serif !important;
line-height: 1.65 !important;
letter-spacing: -0.01em !important;
font-size: 1.15rem;
font-weight: 300;
}
.theme-light ::deep .nexus-ebook {
color: #1a1a1a;
}
/* Technical Code Block Container */
::deep .nexus-ebook pre {
background-color: #2d2d2d; /* Dark theme for code for better contrast */
color: #e0e0e0;
padding: 1.25rem;
border-radius: 8px;
margin: 2rem 0;
overflow-x: auto;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
border-left: 4px solid var(--nexus-neon); /* Nexus neon accent */
/* Dedicated Scrollbar for Code */
scrollbar-width: thin;
scrollbar-color: rgba(0, 255, 153, 0.3) transparent;
}
::deep .nexus-ebook pre::-webkit-scrollbar {
height: 4px;
}
::deep .nexus-ebook pre::-webkit-scrollbar-thumb {
background: rgba(0, 255, 153, 0.3);
border-radius: 10px;
}
/* Monospace Typography Contrast */
::deep .nexus-ebook code {
font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace !important;
font-variant-ligatures: contextual;
line-height: 1.5;
tab-size: 4;
font-size: 0.9rem;
}
/* Inline Code Highlight */
::deep .nexus-ebook p code {
background-color: rgba(0, 0, 0, 0.05);
color: #d63384; /* Classic differentiator for inline code */
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
border: none;
}
.theme-dark ::deep .nexus-ebook p code {
background-color: rgba(255, 255, 255, 0.1);
color: #ff79c6;
}
.block-wrapper.highlighted { .block-wrapper.highlighted {
background: rgba(0, 243, 255, 0.08); background: rgba(0, 243, 255, 0.08);
box-shadow: 0 0 20px rgba(0, 243, 255, 0.15); box-shadow: 0 0 20px rgba(0, 243, 255, 0.15);
@@ -36,10 +36,9 @@
NavigationService.OnNavigationChanged += HandleNavigationChanged; NavigationService.OnNavigationChanged += HandleNavigationChanged;
} }
private Task HandleNavigationChanged() private async Task HandleNavigationChanged()
{ {
StateHasChanged(); await InvokeAsync(StateHasChanged);
return Task.CompletedTask;
} }
private int CalculateProgress() private int CalculateProgress()
@@ -18,9 +18,11 @@
} }
.navigation-controls { .navigation-controls {
display: flex; display: grid;
grid-template-columns: 32px 1fr 32px;
align-items: center; align-items: center;
gap: 1rem; gap: 0.75rem;
width: 260px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -51,18 +53,23 @@
.chapter-info { .chapter-info {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
font-size: 0.85rem; min-width: 0;
overflow: hidden;
color: #333; color: #333;
white-space: nowrap;
} }
.chapter-title { .chapter-title {
font-weight: 600; font-weight: 600;
max-width: 180px; font-size: 0.75rem;
max-width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
line-height: 1.2;
} }
.chapter-count { .chapter-count {
@@ -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="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
<div class="reader-pane"> <div class="reader-pane">
<main> <main>
@Body @Body
</main> </main>
<AuthorizeView>
<Authorized>
<ReaderFooter /> <ReaderFooter />
</Authorized>
</AuthorizeView>
</div> </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,12 +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>
<div class="user-profile">
<span class="user-email">@context.User.Identity?.Name</span>
<button class="logout-btn" @onclick="HandleLogout">Logout</button>
</div>
<button class="close-btn">×</button> <button class="close-btn">×</button>
</div> </div>
@@ -52,7 +50,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</Authorized> </Authorized>
<Authorizing> <Authorizing>
<div class="app-preloader"> <div class="app-preloader">
@@ -60,10 +57,8 @@
<div class="preloader-text">Weryfikacja...</div> <div class="preloader-text">Weryfikacja...</div>
</div> </div>
</Authorizing> </Authorizing>
<NotAuthorized> </AuthorizeView>
@Body </div>
</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.
@@ -77,8 +72,8 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
FocusMode.OnFocusModeChanged += StateHasChanged; FocusMode.OnFocusModeChanged += HandleUpdate;
QuizService.OnQuizUpdated += StateHasChanged; QuizService.OnQuizUpdated += HandleUpdate;
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
if (context.IsSuccess) if (context.IsSuccess)
@@ -93,11 +88,7 @@
} }
} }
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/", true);
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@@ -115,9 +106,11 @@
} }
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
FocusMode.OnFocusModeChanged -= StateHasChanged; FocusMode.OnFocusModeChanged -= HandleUpdate;
QuizService.OnQuizUpdated -= StateHasChanged; QuizService.OnQuizUpdated -= HandleUpdate;
} }
} }
@@ -20,9 +20,10 @@
main { main {
flex: 1; flex: 1;
overflow-y: auto; overflow: hidden;
overflow-x: hidden;
position: relative; position: relative;
display: flex;
flex-direction: column;
} }
.intelligence-sidebar { .intelligence-sidebar {
@@ -32,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,8 +21,7 @@
<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>
@@ -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 class="header-actions">
<button class="btn-logout" @onclick="HandleLogout">
<NexusIcon Name="lock" Size="16" />
Wyloguj się
</button>
</div>
</header>
<div class="stats-grid">
<!-- AI Token Card -->
<div class="stat-card usage-card">
<div class="card-icon">
<NexusIcon Name="robot" Size="24" />
</div>
<div class="card-info">
<h3>Wykorzystanie AI</h3>
<div class="token-numbers">
<span class="tokens-used">@_profile.AITokensUsed</span>
<span class="tokens-limit">/ @_profile.AITokenLimit tokenów</span>
</div>
<div class="usage-bar">
<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>
<!-- Learning Progress Card --> <div class="user-titles">
<div class="stat-card learning-card"> <h1 class="username">@_profile.Email.Split('@')[0]</h1>
<div class="card-icon"> <span class="system-rank">[Nexus_Explorer_@(_profile.TenantId.ToString()[..4])]</span>
<NexusIcon Name="mail" Size="24" />
</div>
<div class="card-info">
<h3>Aktywna Nauka</h3>
<div class="learning-metrics">
<div class="metric">
<span class="label">Średni wynik quizów</span>
<span class="value">@_profile.AverageQuizScore%</span>
</div>
<div class="metric">
<span class="label">Ostatnio czytane</span>
<span class="value truncate">@_profile.LastReadBookTitle</span>
</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> </div>
</section> </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 class="card-body">
<div class="token-usage">
<div class="usage-values">
<span class="current">@_profile.AITokensUsed</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>
<span class="metric-label">Wykorzystane Jednostki Mocy</span>
</div>
</div>
<!-- Sync Card -->
<div class="metric-card glass-panel">
<div class="card-header">
<NexusIcon Name="activity" Size="24" Color="var(--nexus-neon)" />
<h3>Wydajność Nauki</h3>
</div>
<div class="card-body">
<div class="score-display">
<span class="score-value">@_profile.AverageQuizScore%</span>
<span class="score-label">Średni Wynik Asymilacji</span>
</div>
<div class="last-book">
<NexusIcon Name="book-open" Size="14" />
<span class="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 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;
}
}
+10 -7
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
@@ -22,8 +23,8 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
QuizState.OnQuizRequested += HandleQuizRequested; QuizState.OnQuizRequested += HandleQuizRequestedAsync;
FocusMode.OnFocusModeChanged += StateHasChanged; FocusMode.OnFocusModeChanged += HandleUpdate;
await FocusMode.InitializeAsync(); await FocusMode.InitializeAsync();
} }
@@ -54,16 +55,18 @@
} }
} }
private void HandleQuizRequested(string blockId) private async Task HandleQuizRequestedAsync(string blockId)
{ {
_activeQuizBlockId = blockId; _activeQuizBlockId = blockId;
StateHasChanged(); await InvokeAsync(StateHasChanged);
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
QuizState.OnQuizRequested -= HandleQuizRequested; QuizState.OnQuizRequested -= HandleQuizRequestedAsync;
FocusMode.OnFocusModeChanged -= StateHasChanged; FocusMode.OnFocusModeChanged -= HandleUpdate;
if (_interopModule != null && _keydownHandler != null) if (_interopModule != null && _keydownHandler != null)
{ {
@@ -1,8 +1,8 @@
.home-reader-container { .home-reader-container {
width: 100%; width: 100%;
height: 100%;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 2rem;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -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,10 @@
@page "/not-found" @page "/not-found"
@layout MainLayout @layout MainHubLayout
<h3>Not Found</h3> <div class="not-found-preloader">
<p>Sorry, the content you are looking for does not exist.</p> <div class="preloader-robot">
<NexusIcon Name="robot" Size="64" class="neon-pulse" />
<div class="scan-line"></div>
</div>
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Synchronizowanie przestrzeni Nexus...</NexusTypography>
</div>
@@ -0,0 +1,42 @@
.not-found-preloader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 2rem;
}
.preloader-robot {
position: relative;
padding: 1rem;
}
.scan-line {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--nexus-neon);
box-shadow: 0 0 10px var(--nexus-neon);
animation: scan 2s linear infinite;
}
@keyframes scan {
0% { top: 0; }
50% { top: 100%; }
100% { top: 0; }
}
.neon-pulse {
color: var(--nexus-neon);
filter: drop-shadow(0 0 5px var(--nexus-neon));
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
@@ -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>
+4 -1
View File
@@ -2,13 +2,16 @@
<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>
</AuthorizeRouteView> </AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found> </Found>
<NotFound>
<NexusReader.UI.Shared.Pages.NotFound />
</NotFound>
</Router> </Router>
</ChildContent> </ChildContent>
<ErrorContent Context="ex"> <ErrorContent Context="ex">
@@ -6,7 +6,7 @@ public sealed class FocusModeService : IFocusModeService
{ {
private readonly IJSRuntime _jsRuntime; private readonly IJSRuntime _jsRuntime;
public bool IsFocusModeActive { get; private set; } public bool IsFocusModeActive { get; private set; }
public event Action? OnFocusModeChanged; public event Func<Task>? OnFocusModeChanged;
public FocusModeService(IJSRuntime jsRuntime) public FocusModeService(IJSRuntime jsRuntime)
{ {
@@ -21,7 +21,7 @@ public sealed class FocusModeService : IFocusModeService
if (value == "true" && !IsFocusModeActive) if (value == "true" && !IsFocusModeActive)
{ {
IsFocusModeActive = true; IsFocusModeActive = true;
OnFocusModeChanged?.Invoke(); if (OnFocusModeChanged != null) await OnFocusModeChanged();
} }
} }
catch catch
@@ -33,7 +33,7 @@ public sealed class FocusModeService : IFocusModeService
public async Task ToggleAsync() public async Task ToggleAsync()
{ {
IsFocusModeActive = !IsFocusModeActive; IsFocusModeActive = !IsFocusModeActive;
OnFocusModeChanged?.Invoke(); if (OnFocusModeChanged != null) await OnFocusModeChanged();
try try
{ {
@@ -3,7 +3,7 @@ namespace NexusReader.UI.Shared.Services;
public interface IFocusModeService public interface IFocusModeService
{ {
bool IsFocusModeActive { get; } bool IsFocusModeActive { get; }
event Action? OnFocusModeChanged; event Func<Task>? OnFocusModeChanged;
Task InitializeAsync(); Task InitializeAsync();
Task ToggleAsync(); Task ToggleAsync();
} }
@@ -8,12 +8,12 @@ public interface IKnowledgeGraphService
string? ActiveNodeId { get; } string? ActiveNodeId { get; }
bool IsLoading { get; } bool IsLoading { get; }
event Action? OnGraphUpdated; event Func<Task>? OnGraphUpdated;
event Action<string>? OnActiveNodeChanged; event Func<string, Task>? OnActiveNodeChanged;
event Action<bool>? OnLoadingChanged; event Func<bool, Task>? OnLoadingChanged;
void UpdateGraph(GraphDataDto newData); Task UpdateGraph(GraphDataDto newData);
void SetActiveNode(string nodeId); Task SetActiveNode(string nodeId);
void SetLoading(bool isLoading); Task SetLoading(bool isLoading);
void Clear(); Task Clear();
} }
@@ -9,11 +9,11 @@ public interface IQuizStateService
bool IsHydrating { get; } bool IsHydrating { get; }
bool HasNewQuiz { get; } bool HasNewQuiz { get; }
event Action<string>? OnQuizRequested; event Func<string, Task>? OnQuizRequested;
event Action? OnQuizUpdated; event Func<Task>? OnQuizUpdated;
void RequestQuiz(string blockId); Task RequestQuiz(string blockId);
void SetQuiz(string? blockId, QuizDto quiz); Task SetQuiz(string? blockId, QuizDto? quiz);
void SetHydrating(bool hydrating); Task SetHydrating(bool hydrating);
void MarkQuizAsSeen(); Task MarkQuizAsSeen();
} }
@@ -2,15 +2,15 @@ namespace NexusReader.UI.Shared.Services;
public interface IReaderInteractionService public interface IReaderInteractionService
{ {
event Action<string>? OnNodeSelected; event Func<string, Task>? OnNodeSelected;
event Action<string>? OnScrollToBlockRequested; event Func<string, Task>? OnScrollToBlockRequested;
event Action<string>? OnHighlightBlockRequested; event Func<string, Task>? OnHighlightBlockRequested;
event Action<string, string, SelectionCoordinates>? OnTextSelected; event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
void NotifyNodeSelected(string nodeId); Task NotifyNodeSelected(string nodeId);
void RequestScrollToBlock(string blockId); Task RequestScrollToBlock(string blockId);
void RequestHighlightBlock(string blockId); Task RequestHighlightBlock(string blockId);
void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords); Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
} }
public record SelectionCoordinates(double Top, double Left, double Width); public record SelectionCoordinates(double Top, double Left, double Width);
@@ -6,6 +6,6 @@ public interface ISyncService
{ {
Task<Result> InitializeAsync(); Task<Result> InitializeAsync();
Task<Result> UpdateProgressAsync(string pageId); Task<Result> UpdateProgressAsync(string pageId);
event Action<string, DateTime> OnProgressReceived; event Func<string, DateTime, Task> OnProgressReceived;
Task DisposeAsync(); Task DisposeAsync();
} }
@@ -3,6 +3,6 @@ namespace NexusReader.UI.Shared.Services;
public interface IThemeService public interface IThemeService
{ {
bool IsLightMode { get; } bool IsLightMode { get; }
event Action? OnThemeChanged; event Func<Task>? OnThemeChanged;
void ToggleTheme(); Task ToggleTheme();
} }
@@ -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;
@@ -36,10 +36,10 @@ public sealed partial class KnowledgeCoordinator : IDisposable
_interactionService.OnNodeSelected += HandleNodeSelected; _interactionService.OnNodeSelected += HandleNodeSelected;
} }
private void HandleNodeSelected(string nodeId) private async Task HandleNodeSelected(string nodeId)
{ {
_interactionService.RequestScrollToBlock(nodeId); await _interactionService.RequestScrollToBlock(nodeId);
_interactionService.RequestHighlightBlock(nodeId); await _interactionService.RequestHighlightBlock(nodeId);
} }
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global") public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global")
@@ -48,8 +48,8 @@ public sealed partial class KnowledgeCoordinator : IDisposable
LogGeneratingGraph(tenantId); LogGeneratingGraph(tenantId);
_graphService.Clear(); await _graphService.Clear();
_graphService.SetLoading(true); await _graphService.SetLoading(true);
try try
{ {
@@ -59,7 +59,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
var packet = result.Value; var packet = result.Value;
if (packet.Graph != null) if (packet.Graph != null)
{ {
_graphService.UpdateGraph(packet.Graph); await _graphService.UpdateGraph(packet.Graph);
OnGraphUpdated?.Invoke(packet.Graph); OnGraphUpdated?.Invoke(packet.Graph);
await _platformService.VibrateSuccessAsync(); await _platformService.VibrateSuccessAsync();
} }
@@ -71,15 +71,15 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
} }
public void OnBlockReached(string blockId, string content) public async Task OnBlockReachedAsync(string blockId, string content)
{ {
// Only update active node for "TU JESTEŚ" logic, do NOT trigger highlight here // Only update active node for "TU JESTEŚ" logic, do NOT trigger highlight here
_graphService.SetActiveNode(blockId); await _graphService.SetActiveNode(blockId);
} }
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,15 +104,15 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
finally finally
{ {
_quizService.SetHydrating(false); await _quizService.SetHydrating(false);
} }
return null; return null;
} }
public void Clear() public async Task ClearAsync()
{ {
_graphService.Clear(); await _graphService.Clear();
_quizService.SetQuiz(null, null); await _quizService.SetQuiz(null, null);
} }
public void Dispose() public void Dispose()
@@ -8,36 +8,36 @@ public sealed class KnowledgeGraphService : IKnowledgeGraphService
public string? ActiveNodeId { get; private set; } public string? ActiveNodeId { get; private set; }
public bool IsLoading { get; private set; } public bool IsLoading { get; private set; }
public event Action? OnGraphUpdated; public event Func<Task>? OnGraphUpdated;
public event Action<string>? OnActiveNodeChanged; public event Func<string, Task>? OnActiveNodeChanged;
public event Action<bool>? OnLoadingChanged; public event Func<bool, Task>? OnLoadingChanged;
public void UpdateGraph(GraphDataDto newData) public async Task UpdateGraph(GraphDataDto newData)
{ {
CurrentGraphData = newData; CurrentGraphData = newData;
IsLoading = false; IsLoading = false;
OnLoadingChanged?.Invoke(false); if (OnLoadingChanged != null) await OnLoadingChanged(false);
OnGraphUpdated?.Invoke(); if (OnGraphUpdated != null) await OnGraphUpdated();
} }
public void SetActiveNode(string nodeId) public async Task SetActiveNode(string nodeId)
{ {
if (ActiveNodeId == nodeId) return; if (ActiveNodeId == nodeId) return;
ActiveNodeId = nodeId; ActiveNodeId = nodeId;
OnActiveNodeChanged?.Invoke(nodeId); if (OnActiveNodeChanged != null) await OnActiveNodeChanged(nodeId);
} }
public void SetLoading(bool isLoading) public async Task SetLoading(bool isLoading)
{ {
IsLoading = isLoading; IsLoading = isLoading;
OnLoadingChanged?.Invoke(isLoading); if (OnLoadingChanged != null) await OnLoadingChanged(isLoading);
} }
public void Clear() public async Task Clear()
{ {
CurrentGraphData = null; CurrentGraphData = null;
ActiveNodeId = null; ActiveNodeId = null;
IsLoading = false; IsLoading = false;
OnGraphUpdated?.Invoke(); if (OnGraphUpdated != null) await OnGraphUpdated();
} }
} }
@@ -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()));
}
// For opaque tokens, we read the user info that was stored during login
var emailResult = await _storageService.GetSecureString("nexus_user_email"); var emailResult = await _storageService.GetSecureString("nexus_user_email");
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant"); var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
var claims = new List<Claim>();
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value)) if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
{ {
claims.Add(new Claim(ClaimTypes.Name, emailResult.Value)); _cachedState = CreateState(emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer");
claims.Add(new Claim(ClaimTypes.Email, emailResult.Value)); return _cachedState;
} }
if (tenantIdResult.IsSuccess && !string.IsNullOrEmpty(tenantIdResult.Value)) }
// 2. Try Cookie-based auth indicators
var storedEmailResult = await _storageService.GetSecureString("nexus_user_email");
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
{ {
claims.Add(new Claim("TenantId", tenantIdResult.Value)); var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
_cachedState = CreateState(storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth");
return _cachedState;
} }
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);
} }
} }
@@ -9,34 +9,34 @@ public sealed class QuizStateService : IQuizStateService
public bool IsHydrating { get; private set; } public bool IsHydrating { get; private set; }
public bool HasNewQuiz { get; private set; } public bool HasNewQuiz { get; private set; }
public event Action<string>? OnQuizRequested; public event Func<string, Task>? OnQuizRequested;
public event Action? OnQuizUpdated; public event Func<Task>? OnQuizUpdated;
public void RequestQuiz(string blockId) public async Task RequestQuiz(string blockId)
{ {
CurrentQuizBlockId = blockId; CurrentQuizBlockId = blockId;
OnQuizRequested?.Invoke(blockId); if (OnQuizRequested != null) await OnQuizRequested(blockId);
} }
public void SetQuiz(string? blockId, QuizDto quiz) public async Task SetQuiz(string? blockId, QuizDto? quiz)
{ {
CurrentQuizBlockId = blockId; CurrentQuizBlockId = blockId;
CurrentQuiz = quiz; CurrentQuiz = quiz;
IsHydrating = false; IsHydrating = false;
HasNewQuiz = true; HasNewQuiz = quiz != null;
OnQuizUpdated?.Invoke(); if (OnQuizUpdated != null) await OnQuizUpdated();
} }
public void SetHydrating(bool hydrating) public async Task SetHydrating(bool hydrating)
{ {
IsHydrating = hydrating; IsHydrating = hydrating;
OnQuizUpdated?.Invoke(); if (OnQuizUpdated != null) await OnQuizUpdated();
} }
public void MarkQuizAsSeen() public async Task MarkQuizAsSeen()
{ {
if (!HasNewQuiz) return; if (!HasNewQuiz) return;
HasNewQuiz = false; HasNewQuiz = false;
OnQuizUpdated?.Invoke(); if (OnQuizUpdated != null) await OnQuizUpdated();
} }
} }
@@ -2,28 +2,28 @@ namespace NexusReader.UI.Shared.Services;
public sealed class ReaderInteractionService : IReaderInteractionService public sealed class ReaderInteractionService : IReaderInteractionService
{ {
public event Action<string>? OnNodeSelected; public event Func<string, Task>? OnNodeSelected;
public event Action<string>? OnScrollToBlockRequested; public event Func<string, Task>? OnScrollToBlockRequested;
public event Action<string>? OnHighlightBlockRequested; public event Func<string, Task>? OnHighlightBlockRequested;
public event Action<string, string, SelectionCoordinates>? OnTextSelected; public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
public void NotifyNodeSelected(string nodeId) public async Task NotifyNodeSelected(string nodeId)
{ {
OnNodeSelected?.Invoke(nodeId); if (OnNodeSelected != null) await OnNodeSelected(nodeId);
} }
public void RequestScrollToBlock(string blockId) public async Task RequestScrollToBlock(string blockId)
{ {
OnScrollToBlockRequested?.Invoke(blockId); if (OnScrollToBlockRequested != null) await OnScrollToBlockRequested(blockId);
} }
public void RequestHighlightBlock(string blockId) public async Task RequestHighlightBlock(string blockId)
{ {
OnHighlightBlockRequested?.Invoke(blockId); if (OnHighlightBlockRequested != null) await OnHighlightBlockRequested(blockId);
} }
public void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords) public async Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords)
{ {
OnTextSelected?.Invoke(text, blockId, coords); if (OnTextSelected != null) await OnTextSelected(text, blockId, coords);
} }
} }
@@ -14,7 +14,7 @@ public class SyncService : ISyncService, IAsyncDisposable
private bool _isInitialized; private bool _isInitialized;
private CancellationTokenSource? _debounceCts; private CancellationTokenSource? _debounceCts;
public event Action<string, DateTime>? OnProgressReceived; public event Func<string, DateTime, Task>? OnProgressReceived;
public SyncService( public SyncService(
HttpClient httpClient, HttpClient httpClient,
@@ -44,9 +44,9 @@ public class SyncService : ISyncService, IAsyncDisposable
.WithAutomaticReconnect() .WithAutomaticReconnect()
.Build(); .Build();
_hubConnection.On<string, DateTime>("ProgressUpdated", (pageId, timestamp) => _hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) =>
{ {
OnProgressReceived?.Invoke(pageId, timestamp); if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
}); });
try try
@@ -3,11 +3,11 @@ namespace NexusReader.UI.Shared.Services;
public sealed class ThemeService : IThemeService public sealed class ThemeService : IThemeService
{ {
public bool IsLightMode { get; private set; } = false; public bool IsLightMode { get; private set; } = false;
public event Action? OnThemeChanged; public event Func<Task>? OnThemeChanged;
public void ToggleTheme() public async Task ToggleTheme()
{ {
IsLightMode = !IsLightMode; IsLightMode = !IsLightMode;
OnThemeChanged?.Invoke(); if (OnThemeChanged != null) await OnThemeChanged();
} }
} }
@@ -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") {
@@ -134,9 +150,10 @@ export function updateData(data) {
.attr("fill", "none") .attr("fill", "none")
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1) .attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0") .attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
.call(e => e.transition().duration(500).attr("opacity", 1)), .style("opacity", 0)
.call(enter => enter.transition().duration(500).style("opacity", 1)),
update => update, update => update,
exit => exit.remove() exit => exit.transition().duration(500).style("opacity", 0).remove()
); );
// Update Nodes // Update Nodes
@@ -146,8 +163,9 @@ export function updateData(data) {
.join( .join(
enter => { enter => {
const g = enter.append("g") const g = enter.append("g")
.attr("class", "node-group") .attr("class", "node-group neon-flash-node")
.style("cursor", "pointer") .style("cursor", "pointer")
.style("opacity", 0)
.on("click", (e, d) => { .on("click", (e, d) => {
currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id); currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
setActiveNode(d.id); setActiveNode(d.id);
@@ -162,16 +180,15 @@ export function updateData(data) {
if (d.type === 'Rule') return '#ff4444'; if (d.type === 'Rule') return '#ff4444';
return "url(#nebulaGlow)"; return "url(#nebulaGlow)";
}) })
.attr("opacity", 0) .attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
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)';
@@ -181,21 +198,29 @@ 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);
return g; return g;
}, },
update => update, update => update.classed("neon-flash-node", false),
exit => exit.remove() exit => exit.transition().duration(500).style("opacity", 0).remove()
); );
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) {
@@ -222,32 +247,59 @@ 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()) return; if (targetNode.empty()) {
dimNodes(null);
badge.style("display", "none");
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})`);
// Smooth transition // Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId);
// 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)
); );
} }
export function dimNodes(activeNodeId) {
if (!node) return;
node.transition().duration(500)
.style("opacity", d => (activeNodeId === null || d.id === activeNodeId) ? 1 : 0.4);
if (link) {
link.transition().duration(500)
.style("opacity", d => {
if (activeNodeId === null) return 1;
// Check if this link is connected to the active node
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
return (sourceId === activeNodeId || targetId === activeNodeId) ? 1 : 0.1;
});
}
}
export function unmount(containerId) { 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) {
@@ -293,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() {
@@ -0,0 +1,31 @@
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Client.Handlers;
public class AuthenticationHeaderHandler : DelegatingHandler
{
private readonly INativeStorageService _storageService;
private const string TokenKey = "nexus_auth_token";
public AuthenticationHeaderHandler(INativeStorageService storageService)
{
_storageService = storageService;
}
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);
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.Value);
}
return await base.SendAsync(request, cancellationToken);
}
}
@@ -12,6 +12,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="12.1.1" /> <PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+28 -1
View File
@@ -4,6 +4,9 @@ using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services; using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
using NexusReader.Application; using NexusReader.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using NexusReader.Data.Persistence;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -30,9 +33,33 @@ builder.Services.AddCascadingAuthenticationState();
// AI & Content Services // AI & Content Services
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>(); builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
}).AddHttpMessageHandler<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
// Dummy registrations for server-only handlers to satisfy DI validation
builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbContextFactory());
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddScoped<IEpubService, WasmEpubService>(); builder.Services.AddScoped<IEpubService, WasmEpubService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
public class ThrowingDbContextFactory : IDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client.");
}
public class ThrowingEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
public void Dispose() { }
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Embedding generation cannot be used in WASM client.");
public object? GetService(Type serviceType, object? serviceKey = null) => null;
}
+28 -4
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,9 +92,31 @@ 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 =>
{
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 = StatusCodes.Status401Unauthorized;
}
else
{
context.Response.Redirect(context.RedirectUri);
}
return Task.CompletedTask;
};
}); });
builder.Services.Configure<IdentityOptions>(options => builder.Services.Configure<IdentityOptions>(options =>
@@ -422,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,
@@ -429,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;
}
}