2 Commits

Author SHA1 Message Date
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
42 changed files with 514 additions and 192 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`.
+6 -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).
@@ -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;
} }
@@ -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;
}
} }
@@ -40,6 +40,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: 3.6 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;
@@ -2,6 +2,8 @@
@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">
@@ -36,13 +38,16 @@
<button class="toolbar-item" title="Global Settings"> <button class="toolbar-item" title="Global Settings">
<NexusIcon Name="settings" Size="20" /> <NexusIcon Name="settings" Size="20" />
</button> </button>
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Logout">
<NexusIcon Name="log-out" Size="20" />
</button>
</div> </div>
</aside> </aside>
@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 {
@@ -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
@@ -98,3 +98,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 {
@@ -35,10 +35,7 @@
<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>
@@ -77,8 +74,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 +90,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 +108,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 {
+8 -6
View File
@@ -22,8 +22,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 +54,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);
} }
@@ -1,5 +1,10 @@
@page "/not-found" @page "/not-found"
@layout MainLayout @layout Layout.MainLayout
<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; }
}
+3
View File
@@ -9,6 +9,9 @@
</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();
} }
@@ -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,10 +71,10 @@ 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")
@@ -109,9 +109,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
return null; return null;
} }
public void Clear() public async Task ClearAsync()
{ {
_graphService.Clear(); await _graphService.Clear();
_quizService.SetQuiz(null, null); _quizService.SetQuiz(null, null);
} }
@@ -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();
} }
} }
@@ -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();
} }
} }
@@ -134,9 +134,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 +147,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,8 +164,7 @@ 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")
@@ -187,10 +188,12 @@ export function updateData(data) {
.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.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);
@@ -223,7 +226,11 @@ export function setActiveNode(nodeId) {
if (!svgElement || !node) return; if (!svgElement || !node) return;
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 d = targetNode.datum();
@@ -235,6 +242,9 @@ export function setActiveNode(nodeId) {
badge.style("display", "block").datum(d); badge.style("display", "block").datum(d);
badge.attr("transform", `translate(${d.x},${d.y})`); badge.attr("transform", `translate(${d.x},${d.y})`);
// Dim others
dimNodes(nodeId);
// Smooth transition // Smooth transition
svgElement.transition().duration(1000).call( svgElement.transition().duration(1000).call(
zoomBehavior.transform, zoomBehavior.transform,
@@ -242,6 +252,24 @@ export function setActiveNode(nodeId) {
); );
} }
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();
@@ -0,0 +1,27 @@
using System.Net.Http.Headers;
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)
{
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;
}
+12
View File
@@ -96,6 +96,18 @@ builder.Services.ConfigureApplicationCookie(options =>
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30); options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true; options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = 401;
}
else
{
context.Response.Redirect(context.RedirectUri);
}
return Task.CompletedTask;
};
}); });
builder.Services.Configure<IdentityOptions>(options => builder.Services.Configure<IdentityOptions>(options =>