style(ui): refactor reader layout grid, fix focus mode layout collapse, fix SVG rendering dots, reorganize intelligence toolbar #69

Merged
mjasin merged 10 commits from feature/reader-visual-refactor into develop 2026-06-05 09:51:29 +00:00
24 changed files with 2022 additions and 571 deletions
+10
View File
@@ -36,3 +36,13 @@ Run test suite:
```bash
dotnet test --no-restore
```
## 🗄️ Database Migrations
Automatic database migrations at startup (`MigrateAsync()`) have been disabled to ensure compatibility with Native AOT compilation and prevent locking issues in multi-instance environments.
To apply database migrations locally, run the EF Core migration command from the solution root:
```bash
dotnet ef database update --project src/NexusReader.Infrastructure --startup-project src/NexusReader.Web
```
@@ -33,10 +33,11 @@
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
break;
case "search":
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="21" y1="21" x2="16.65" y2="16.65" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
break;
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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "diamond":
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
@@ -56,34 +57,49 @@
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
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" 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" />
<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;
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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "target":
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="2" fill="none" stroke="currentColor" stroke-width="2" />
break;
case "trash":
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
<polyline points="3 6 5 6 21 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="10" y1="11" x2="10" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="14" y1="11" x2="14" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "mail":
<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
<rect width="20" height="16" x="2" y="4" rx="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "lock":
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "eye":
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" />
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" 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;
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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="2" x2="22" y1="2" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "arrow-left":
<path d="M19 12H5M12 19l-7-7 7-7" />
<line x1="19" y1="12" x2="5" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="12 19 5 12 12 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "arrow-right":
<path d="M5 12h14M12 5l7 7-7 7" />
<line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
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" />
@@ -99,6 +115,13 @@
<line x1="18" y1="6" x2="6" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="6" y1="6" x2="18" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "sun":
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "moon":
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
default:
<!-- Fallback circle -->
<circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,47 @@
@namespace NexusReader.UI.Shared.Components.Molecules
<div class="nexus-callout-box nexus-callout-@Type.ToString().ToLower() @Class">
@if (!string.IsNullOrEmpty(Title))
{
<div class="nexus-callout-header">
@if (Type == CalloutType.Warning || Type == CalloutType.Error)
{
<NexusIcon Name="warning" Size="16" Class="nexus-callout-icon" />
}
else if (Type == CalloutType.Success)
{
<NexusIcon Name="check" Size="16" Class="nexus-callout-icon" />
}
else
{
<NexusIcon Name="info" Size="16" Class="nexus-callout-icon" />
}
<span class="nexus-callout-title">@Title</span>
</div>
}
<div class="nexus-callout-body">
@ChildContent
</div>
</div>
@code {
public enum CalloutType
{
Info,
Warning,
Success,
Error
}
[Parameter]
public CalloutType Type { get; set; } = CalloutType.Info;
[Parameter]
public string? Title { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string Class { get; set; } = string.Empty;
}
@@ -0,0 +1,122 @@
.nexus-callout-box {
padding: 1rem 1.25rem;
margin: 1.5rem 0 1.5rem 0;
border-radius: 0 8px 8px 0;
font-family: var(--nexus-font-sans, sans-serif);
font-size: 0.95rem;
line-height: 1.5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
border: 1px solid transparent;
border-left-width: 4px;
}
/* Light / Dark default support via variables or custom colors */
.nexus-callout-box {
background-color: rgba(255, 255, 255, 0.02);
color: #e2e8f0;
}
/* Info style */
.nexus-callout-info {
border-left-color: var(--nexus-neon, #00ff99);
}
/* Warning style */
.nexus-callout-warning {
border-left-color: #eab308; /* warning yellow */
background-color: rgba(234, 179, 8, 0.03);
}
/* Success style */
.nexus-callout-success {
border-left-color: #10b981; /* success green */
background-color: rgba(16, 185, 129, 0.03);
}
/* Error style */
.nexus-callout-error {
border-left-color: #f43f5e; /* error red */
background-color: rgba(244, 63, 94, 0.03);
}
.nexus-callout-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.05em;
}
.nexus-callout-info .nexus-callout-header {
color: var(--nexus-neon, #00ff99);
}
.nexus-callout-warning .nexus-callout-header {
color: #eab308;
}
.nexus-callout-success .nexus-callout-header {
color: #10b981;
}
.nexus-callout-error .nexus-callout-header {
color: #f43f5e;
}
.nexus-callout-icon {
flex-shrink: 0;
}
.nexus-callout-body {
opacity: 0.9;
}
/* Light theme support */
.theme-light .nexus-callout-box {
background-color: #fcfcfb;
border: 1px solid rgba(0, 0, 0, 0.03);
border-left-width: 4px;
color: #44403c;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.015);
}
.theme-light .nexus-callout-info {
border-left-color: #10b981;
background-color: rgba(16, 185, 129, 0.04);
}
.theme-light .nexus-callout-info .nexus-callout-header {
color: #059669;
}
.theme-light .nexus-callout-warning {
border-left-color: #d97706;
background-color: rgba(217, 119, 6, 0.04);
}
.theme-light .nexus-callout-warning .nexus-callout-header {
color: #d97706;
}
.theme-light .nexus-callout-success {
border-left-color: #10b981;
background-color: rgba(16, 185, 129, 0.04);
}
.theme-light .nexus-callout-success .nexus-callout-header {
color: #059669;
}
.theme-light .nexus-callout-error {
border-left-color: #e11d48;
background-color: rgba(225, 29, 72, 0.04);
}
.theme-light .nexus-callout-error .nexus-callout-header {
color: #e11d48;
}
@@ -1,45 +1,43 @@
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.Abstractions.Services
@inject IFocusModeService FocusMode
@inject IKnowledgeService KnowledgeService
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
@inject IThemeService ThemeService
@inject IKnowledgeService KnowledgeService
@implements IDisposable
<aside class="intelligence-toolbar">
<div class="toolbar-top">
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Back to Dashboard">
<NexusIcon Name="arrow-left" Size="20" />
</button>
<button class="toolbar-item active" title="Chat">
@if (FocusMode.IsFocusModeActive)
{
<button class="toolbar-item active" @onclick="FocusMode.ToggleAsync" title="Focus Mode Active (Click to Exit)">
<NexusIcon Name="target" Size="20" />
</button>
}
else
{
<button class="toolbar-item active" @onclick="FocusMode.ToggleAsync" title="Chat Active (Click to Focus)">
<NexusIcon Name="message-square" Size="20" />
</button>
</div>
<div class="toolbar-middle">
<button class="toolbar-item" title="Settings">
<NexusIcon Name="settings" Size="20" />
</button>
<button class="toolbar-item" title="Bookmarks">
<NexusIcon Name="bookmark" Size="20" />
</button>
<button class="toolbar-item" title="Search">
<NexusIcon Name="search" Size="20" />
</button>
<button class="toolbar-item danger" @onclick="HandleClearCache" title="Clear AI Cache">
<NexusIcon Name="trash" Size="20" />
</button>
}
</div>
<div class="toolbar-bottom">
<button class="toolbar-item @(FocusMode.IsFocusModeActive ? "active focus-active" : "")"
@onclick="FocusMode.ToggleAsync" title="Focus Mode (F)">
<NexusIcon Name="target" Size="20" />
<div class="toolbar-separator"></div>
<button class="toolbar-item" @onclick="ThemeService.ToggleTheme" title="Przełącz motyw">
<NexusIcon Name="@(ThemeService.IsLightMode ? "sun" : "moon")" Size="20" />
</button>
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Global Hub">
<NexusIcon Name="layers" Size="20" />
</button>
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
<NexusIcon Name="log-out" Size="20" />
<div class="toolbar-separator"></div>
<button class="toolbar-item clear-cache-item" @onclick="HandleClearCache" title="Wyczyść pamięć AI">
<NexusIcon Name="trash" Size="20" />
</button>
</div>
</aside>
@@ -48,11 +46,11 @@
protected override void OnInitialized()
{
FocusMode.OnFocusModeChanged += HandleUpdate;
ThemeService.OnThemeChanged += HandleThemeChangedAsync;
}
private async Task HandleClearCache()
{
// For now, a simple console log confirm or just do it
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
var result = await KnowledgeService.ClearCacheAsync();
if (result.IsSuccess)
@@ -61,16 +59,13 @@
}
}
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/account/logout-form", true);
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged);
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟢 Minor/Suggestion: The method HandleThemeChangedAsync does not need to be marked as async Task since it only awaits a single task and does not contain other logic.

You can simplify it to avoid state machine generation overhead:

private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged);
🟢 Minor/Suggestion: The method `HandleThemeChangedAsync` does not need to be marked as `async Task` since it only awaits a single task and does not contain other logic. You can simplify it to avoid state machine generation overhead: ```csharp private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged); ```
public void Dispose()
{
FocusMode.OnFocusModeChanged -= HandleUpdate;
ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
}
}
@@ -71,26 +71,53 @@
transform: rotate(180deg);
}
.toolbar-item.danger:hover {
color: #ff4d4d;
background: rgba(255, 77, 77, 0.1);
.toolbar-separator {
width: 24px;
height: 1px;
background: rgba(255, 255, 255, 0.08);
margin: 0.2rem 0;
}
.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;
/* Light mode overrides */
.theme-light .intelligence-toolbar {
background: #f5f5f4;
border-right: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: inset -2px 0 10px rgba(0, 0, 0, 0.02);
}
.toolbar-item.logout-item:hover {
color: #ff4d4d;
background: none;
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.4));
.theme-light .toolbar-item {
color: #78716c;
}
.theme-light .toolbar-item:hover {
color: #10b981;
background: rgba(16, 185, 129, 0.05);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
filter: none;
}
.theme-light .toolbar-item.active {
color: #10b981;
background: rgba(16, 185, 129, 0.08);
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
filter: none;
}
.theme-light .toolbar-item.active::after {
background: #10b981;
box-shadow: none;
}
.theme-light .toolbar-item.focus-active {
color: #10b981;
filter: none;
}
.theme-light .toolbar-separator {
background: rgba(0, 0, 0, 0.08);
}
@@ -335,3 +335,175 @@
box-shadow: none;
}
/* Light mode overrides */
.theme-light .knowledge-check {
background: #fafaf9;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
}
.theme-light .header-title {
color: #1c1917;
}
.theme-light .question-text {
color: #44403c;
}
.theme-light .option-item {
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.theme-light .option-item:hover {
background: #f5f5f4;
}
.theme-light .option-item.selected {
border-color: #10b981;
background: rgba(16, 185, 129, 0.04);
}
.theme-light .option-letter {
color: #059669;
}
.theme-light .option-text {
color: #292524;
}
.theme-light .option-correct {
border-color: #10b981 !important;
background: rgba(16, 185, 129, 0.08) !important;
}
.theme-light .option-incorrect {
border-color: #f43f5e !important;
background: rgba(244, 63, 94, 0.08) !important;
}
.theme-light .option-revealed-correct {
border-color: #10b981 !important;
background: rgba(16, 185, 129, 0.06) !important;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.1);
}
.theme-light .loading-state.shimmer {
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.03), transparent);
color: #10b981;
text-shadow: none;
}
.theme-light .submit-btn {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.15);
color: #78716c;
}
.theme-light .submit-btn:not(:disabled) {
background: #10b981;
color: #ffffff;
border-color: #10b981;
}
.theme-light .submitted-title {
color: #1c1917;
}
.theme-light .submitted-text {
color: #78716c;
}
.theme-light .score-card {
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
}
.theme-light .score-num {
color: #10b981;
text-shadow: none;
}
.theme-light .score-divider {
color: #e7e5e4;
}
.theme-light .score-total {
color: #292524;
}
.theme-light .score-percent {
color: #78716c;
}
.theme-light .reset-quiz-btn {
border: 1px solid rgba(0, 0, 0, 0.15);
color: #44403c;
}
.theme-light .reset-quiz-btn:hover {
background: rgba(0, 0, 0, 0.03);
border-color: #1c1917;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.03);
}
.theme-light .empty-title {
color: #1c1917;
}
.theme-light .empty-text {
color: #78716c;
}
.theme-light .empty-icon-wrapper {
background: rgba(16, 185, 129, 0.02);
border: 1px solid rgba(16, 185, 129, 0.1);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.02);
}
.theme-light .empty-quiz-state:hover .empty-icon-wrapper {
background: rgba(16, 185, 129, 0.06);
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 0 25px rgba(16, 185, 129, 0.1);
}
.theme-light .generate-quiz-btn {
background: rgba(16, 185, 129, 0.05);
border: 1px solid #10b981;
color: #10b981;
text-shadow: none;
box-shadow: 0 0 15px rgba(16, 185, 129, 0.05);
}
.theme-light .generate-quiz-btn:not(:disabled):hover {
background: #10b981;
color: #ffffff;
box-shadow: 0 0 25px rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
.theme-light .generate-quiz-btn:disabled {
border-color: rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.02);
color: #a8a29e;
box-shadow: none;
}
.theme-light .success-icon-wrapper {
background: rgba(16, 185, 129, 0.05);
border: 1px solid rgba(16, 185, 129, 0.2);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.08);
}
.theme-light .success-glow {
color: #10b981;
filter: none;
}
.theme-light .neon-glow {
color: #10b981;
filter: none;
}
@@ -3,49 +3,47 @@
@using NexusReader.Application.DTOs.AI
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
@inject IQuizStateService QuizService
@inject IJSRuntime JS
@if (IsVisible)
{
<div class="selection-ai-panel expanded @(PositionBelow ? "below" : "")" style="@PanelStyle">
<div class="ai-bubble">
<div class="ai-avatar">
<div class="avatar-ring"></div>
<NexusIcon Name="robot" Size="48" Class="neon-pulse" />
<div class="avatar-label">
<span class="name">E-Czytnik</span>
<span class="role">Asystent AI</span>
</div>
</div>
<div class="ai-content">
@if (IsLoading)
<div class="selection-ai-panel @(_positionBelow ? "below" : "")" style="@_style">
<button id="summary-btn" class="toolbar-btn primary @(IsLoadingSummary ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
disabled="@IsAnyLoading"
@onclick="RequestSummaryAsync">
@if (IsLoadingSummary)
{
<div class="loading-state">
<div class="shimmer">Skanowanie fragmentu...</div>
</div>
}
else if (Packet != null)
{
<div class="summary-box">
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Packet.Summary</NexusTypography>
</div>
<div class="ai-actions">
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
<button class="action-btn ghost" @onclick="CloseAsync">Zamknij</button>
</div>
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
</svg>
<span class="btn-text">Podsumowywanie...</span>
}
else
{
<div class="summary-box">
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?</NexusTypography>
</div>
<div class="ai-actions">
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
<button class="action-btn ghost" @onclick="CloseAsync">Pomiń</button>
</div>
<NexusIcon Name="book-open" Size="14" Class="btn-icon" />
<span class="btn-text">Podsumuj</span>
}
</div>
<div class="bubble-pointer"></div>
</div>
</button>
<div class="toolbar-divider"></div>
<button id="quiz-btn" class="toolbar-btn secondary @(IsLoadingQuiz ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
disabled="@IsAnyLoading"
@onclick="GenerateQuizAsync">
@if (IsLoadingQuiz)
{
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
</svg>
<span class="btn-text">Generowanie...</span>
}
else
{
<NexusIcon Name="target" Size="14" Class="btn-icon" />
<span class="btn-text">Quiz</span>
}
</button>
</div>
}
@@ -56,47 +54,145 @@
[Parameter] public string FullPageContent { get; set; } = string.Empty;
private bool IsVisible => !string.IsNullOrEmpty(SelectedText) && Coordinates != null;
private bool IsLoading = false;
private KnowledgePacket? Packet;
private bool PositionBelow => Coordinates != null && Coordinates.Top < 320;
private bool IsLoadingSummary = false;
private bool IsLoadingQuiz = false;
private bool IsAnyLoading => IsLoadingSummary || IsLoadingQuiz;
private string _style = "visibility: hidden; opacity: 0; pointer-events: none;";
private bool _positionBelow = false;
private SelectionCoordinates? _lastCoordinates;
protected override void OnParametersSet()
{
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}, PositionBelow: {PositionBelow}");
// Reset packet when selection changes
Packet = null;
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}");
if (Coordinates != _lastCoordinates)
{
_lastCoordinates = Coordinates;
_style = "visibility: hidden; opacity: 0; pointer-events: none;";
_positionBelow = false;
}
private string PanelStyle => Coordinates != null
? string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"top: {(PositionBelow ? Coordinates.Top + 35 : Coordinates.Top - 15):F1}px !important; " +
$"left: {Math.Clamp(Coordinates.Left + Coordinates.Width / 2, 280, 1600):F1}px !important; " +
$"transform: translate(-50%, {(PositionBelow ? "0" : "-100%")}) !important;")
: "";
// Reset loading states when parameters change
IsLoadingSummary = false;
IsLoadingQuiz = false;
}
private async Task RequestSummary()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (IsVisible && _style.Contains("visibility: hidden"))
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var result = await module.InvokeAsync<PositionResult>("positionToolbar");
if (result != null)
{
_style = string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"left: {result.Left:F1}px !important; " +
$"top: {result.Top:F1}px !important; " +
$"visibility: visible !important; " +
$"opacity: 1 !important; " +
$"pointer-events: auto !important;");
_positionBelow = result.Below;
StateHasChanged();
}
}
catch (Exception ex)
{
Console.WriteLine($"[SelectionAiPanel] Error positioning toolbar: {ex.Message}");
}
}
}
private async Task RequestSummaryAsync()
{
if (IsAnyLoading) return;
IsLoadingSummary = true;
StateHasChanged();
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var selectedText = await module.InvokeAsync<string>("getSelectionText");
if (string.IsNullOrWhiteSpace(selectedText))
{
selectedText = SelectedText;
}
if (!string.IsNullOrWhiteSpace(selectedText))
{
IsLoading = true;
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: "";
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
Packet = result.IsSuccess ? result.Value : null;
IsLoading = false;
_ = Coordinator.StartSelectionSummaryAsync($"{contextPrompt}{selectedText}");
await CloseAsync();
await InteractionService.RequestAssistant();
}
}
catch (Exception ex)
{
Console.WriteLine($"[SelectionAiPanel] Error requesting summary: {ex.Message}");
}
finally
{
IsLoadingSummary = false;
StateHasChanged();
}
}
private async Task GenerateFullQuiz()
private async Task GenerateQuizAsync()
{
if (IsAnyLoading) return;
IsLoadingQuiz = true;
StateHasChanged();
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var selectedText = await module.InvokeAsync<string>("getSelectionText");
if (string.IsNullOrWhiteSpace(selectedText))
{
selectedText = SelectedText;
}
if (!string.IsNullOrWhiteSpace(selectedText))
{
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: "";
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{selectedText}");
if (result.IsSuccess)
{
IsLoading = true;
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
IsLoading = false;
await CloseAsync();
await QuizService.RequestQuiz(BlockId);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}");
}
finally
{
IsLoadingQuiz = false;
StateHasChanged();
}
}
private async Task CloseAsync()
{
Packet = null;
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
}
private class PositionResult
{
public double Left { get; set; }
public double Top { get; set; }
public bool Below { get; set; }
}
}
@@ -1,158 +1,149 @@
.selection-ai-panel {
position: fixed;
z-index: 9999;
width: 550px;
max-width: 90vw;
animation: fadeInScale 0.2s ease-out;
pointer-events: auto;
position: absolute;
z-index: 10000;
display: flex;
align-items: center;
background: rgba(24, 24, 28, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4);
padding: 4px 6px;
gap: 4px;
pointer-events: none; /* Controlled by inline styles */
user-select: none;
animation: fadeInScale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeInScale {
from { opacity: 0; transform: translate(-50%, -90%) scale(0.95); }
to { opacity: 1; transform: translate(-50%, -100%) scale(1); }
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.ai-bubble {
position: relative;
display: flex;
flex-direction: row;
gap: 1.5rem;
padding: 1.5rem;
background: rgba(18, 18, 18, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
color: #fff;
.selection-ai-panel.below {
animation: fadeInScaleBelow 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
.ai-avatar {
@keyframes fadeInScaleBelow {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.toolbar-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 100px;
}
.avatar-label {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.avatar-label .name {
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
border-radius: 6px;
color: #e4e4e7; /* zinc-200 */
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.avatar-label .role {
font-size: 0.7rem;
opacity: 0.6;
}
.neon-pulse {
color: #00ff99;
filter: drop-shadow(0 0 8px #00ff99);
animation: pulse 2s infinite ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
50% { transform: scale(1.05); filter: drop-shadow(0 0 15px #00ff99); }
100% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
}
.ai-content {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: center;
}
.summary-box {
font-size: 0.95rem;
line-height: 1.5;
color: #e0e0e0;
max-height: 40vh;
overflow-y: auto;
padding-right: 8px;
}
.summary-box::-webkit-scrollbar {
width: 4px;
}
.summary-box::-webkit-scrollbar-thumb {
background: rgba(0, 255, 153, 0.3);
border-radius: 2px;
}
.ai-actions {
display: flex;
gap: 1rem;
}
.action-btn {
padding: 0.5rem 1.2rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
font-family: inherit;
}
.action-btn.ghost {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #aaa;
.toolbar-btn:hover:not(.disabled) {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
.action-btn.neon-border {
background: rgba(0, 255, 153, 0.1);
border: 1px solid #00ff99;
color: #00ff99;
.toolbar-btn.primary {
color: var(--nexus-neon, #00ff99);
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 255, 153, 0.2);
.toolbar-btn.primary:hover:not(.disabled) {
background: rgba(0, 255, 153, 0.08);
box-shadow: 0 0 12px rgba(0, 255, 153, 0.15);
}
.bubble-pointer {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
.toolbar-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.selection-ai-panel:not(.below) .bubble-pointer {
bottom: -10px;
border-top: 10px solid rgba(18, 18, 18, 0.95);
.toolbar-divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.1);
}
.selection-ai-panel.below .bubble-pointer {
top: -10px;
border-bottom: 10px solid rgba(18, 18, 18, 0.95);
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.loading-state {
padding: 1rem;
.spinner-inline {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
flex-shrink: 0;
}
.shimmer {
background: linear-gradient(90deg, transparent, rgba(0, 255, 153, 0.2), transparent);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
padding: 0.5rem;
border-radius: 4px;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
.opacity-50 {
opacity: 0.5 !important;
}
.cursor-not-allowed {
cursor: not-allowed !important;
}
/* Light mode overrides */
.theme-light .selection-ai-panel {
background: rgba(254, 254, 254, 0.95);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04);
}
.theme-light .toolbar-btn {
color: #57524e;
}
.theme-light .toolbar-btn:hover:not(.disabled) {
background: rgba(0, 0, 0, 0.04);
color: #1c1917;
}
.theme-light .toolbar-btn.primary {
color: #10b981;
}
.theme-light .toolbar-btn.primary:hover:not(.disabled) {
background: rgba(16, 185, 129, 0.06);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
}
.theme-light .toolbar-divider {
background: rgba(0, 0, 0, 0.08);
}
@@ -41,7 +41,7 @@
</div>
</div>
<div class="verification-state" style="@(IsVerifying ? "display:flex;" : "display:none;")">
<div class="verification-state" style="@((IsVerifying && !IsIngesting) ? "display:flex;" : "display:none;")">
@if (Metadata != null)
{
<div class="verification-layout">
@@ -196,52 +196,37 @@
margin-top: 1rem;
}
.btn {
font-family: var(--nexus-font-sans);
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.85rem;
letter-spacing: 0.5px;
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
::deep .nexus-btn.btn-primary {
background: var(--nexus-neon, #00ffaa) !important;
color: #050505 !important;
border-color: transparent !important;
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2) !important;
}
.btn-primary {
background: var(--nexus-neon, #00ffaa);
color: #050505;
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2);
::deep .nexus-btn.btn-primary:hover:not(:disabled) {
background: #00e699 !important;
transform: translateY(-2px) !important;
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4) !important;
}
.btn-primary:hover {
background: #00e699;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4);
::deep .nexus-btn.btn-primary:active:not(:disabled) {
transform: translateY(0) !important;
}
.btn-primary:active {
transform: translateY(0);
::deep .nexus-btn.btn-secondary {
background: rgba(255, 255, 255, 0.03) !important;
color: var(--nexus-text) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.03);
color: var(--nexus-text);
border: 1px solid rgba(255, 255, 255, 0.1);
::deep .nexus-btn.btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px) !important;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.btn-secondary:active {
transform: translateY(0);
::deep .nexus-btn.btn-secondary:active:not(:disabled) {
transform: translateY(0) !important;
}
/* Verification State */
@@ -357,27 +342,30 @@
to { transform: scale(1.2); opacity: 0.8; }
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
::deep .nexus-btn:disabled:not(.btn-loading) {
opacity: 0.4 !important;
cursor: not-allowed !important;
filter: grayscale(1) !important;
}
.btn-loading {
position: relative;
::deep .nexus-btn.btn-loading {
position: relative !important;
color: transparent !important;
opacity: 1 !important;
cursor: wait !important;
filter: none !important;
}
.btn-loading::after {
content: "";
position: absolute;
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--nexus-neon, #00ffaa);
border-radius: 50%;
animation: spin 0.8s linear infinite;
filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa));
::deep .nexus-btn.btn-loading::after {
content: "" !important;
position: absolute !important;
width: 20px !important;
height: 20px !important;
border: 2px solid rgba(255, 255, 255, 0.2) !important;
border-top-color: var(--nexus-neon, #00ffaa) !important;
border-radius: 50% !important;
animation: spin 0.8s linear infinite !important;
filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa)) !important;
}
/* Indexing State */
@@ -10,7 +10,7 @@
position: relative;
}
.knowledge-graph-container.loading > ::deep svg {
.knowledge-graph-container.loading> ::deep svg {
display: none !important;
}
@@ -93,9 +93,20 @@
}
@keyframes robot-pulse {
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
50% { transform: scale(1.1); filter: drop-shadow(0 0 25px var(--nexus-neon)); }
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
0% {
transform: scale(1);
filter: drop-shadow(0 0 10px var(--nexus-neon));
}
50% {
transform: scale(1.1);
filter: drop-shadow(0 0 25px var(--nexus-neon));
}
100% {
transform: scale(1);
filter: drop-shadow(0 0 10px var(--nexus-neon));
}
}
.scan-line {
@@ -111,9 +122,17 @@
}
@keyframes scan {
0% { top: 0; }
50% { top: 100%; }
100% { top: 0; }
0% {
top: 0;
}
50% {
top: 100%;
}
100% {
top: 0;
}
}
::deep .nexus-node-active {
@@ -124,11 +143,24 @@
}
::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)); }
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;
}
.knowledge-graph-container ::deep svg {
background: radial-gradient(circle, #1a1a1a 0%, #121212 100%);
transition: background 0.3s ease;
}
@@ -99,6 +99,7 @@
private bool _isMobile = false;
private DotNetObjectReference<ReaderCanvas>? _selfReference;
private IJSObjectReference? _viewportModule;
private IJSObjectReference? _selectionModule;
protected override async Task OnInitializedAsync()
{
@@ -201,10 +202,13 @@
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
if (_selectionModule == null)
{
_selectionModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
}
if (_selfReference != null)
{
await module.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
await _selectionModule.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
}
}
catch (Exception ex)
1
@@ -440,6 +444,19 @@
InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
try
{
if (_selectionModule != null)
{
await _selectionModule.InvokeVoidAsync("destroySelectionListener");
await _selectionModule.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to destroy JS selection listener.");
}
try
{
if (_viewportModule != null)
@@ -30,18 +30,40 @@
background-color: rgba(0, 255, 153, 0.5);
}
.reader-canvas.theme-dark {
background-color: #121214;
}
.reader-canvas.theme-light {
background-color: #F9F9F9; /* Paper-white requirement */
background-color: #f4f1ea;
/* Warm light beige/gray background */
}
.reader-flow-container {
max-width: 800px;
margin: 0 auto;
max-width: 680px;
margin: 2rem auto;
min-height: calc(100vh - 180px);
display: flex;
flex-direction: column;
gap: 1.5rem;
position: relative;
padding: 0 1.5rem 15rem 1.5rem; /* Large padding-bottom for reachability */
padding: 3rem 4rem 15rem 4rem;
/* Large padding-bottom for reachability, plus comfortable side margins */
border-radius: 12px;
box-sizing: border-box;
transition: background-color 0.3s, box-shadow 0.3s, border-color 0.3s;
}
.theme-dark .reader-flow-container {
background-color: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.03);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
}
.theme-light .reader-flow-container {
background-color: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 4px 20px rgba(139, 130, 115, 0.12);
}
.block-wrapper {
@@ -57,23 +79,48 @@
line-height: 1.65 !important;
letter-spacing: -0.01em !important;
font-size: 1.15rem;
font-weight: 300;
font-weight: 400;
text-align: left !important;
color: #e4e4e7;
/* Off-white with light gray tint */
}
.theme-light ::deep .nexus-ebook {
color: #1a1a1a;
color: #292524;
/* Warm charcoal for legibility */
}
/* Callout Box styling for legacy blockquote segments */
::deep .nexus-ebook blockquote {
background-color: rgba(255, 255, 255, 0.02);
border-left: 4px solid var(--nexus-neon);
padding: 1rem 1.25rem;
margin: 1.5rem 0 1.5rem 0;
border-radius: 0 8px 8px 0;
font-size: 1.05rem;
color: #e2e8f0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.theme-light ::deep .nexus-ebook blockquote {
background-color: rgba(245, 158, 11, 0.04);
border-left: 4px solid #f59e0b;
color: #44403c;
}
/* Technical Code Block Container */
::deep .nexus-ebook pre {
background-color: #2d2d2d; /* Dark theme for code for better contrast */
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 */
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;
@@ -101,7 +148,8 @@
/* Inline Code Highlight */
::deep .nexus-ebook p code {
background-color: rgba(0, 0, 0, 0.05);
color: #d63384; /* Classic differentiator for inline code */
color: #d63384;
/* Classic differentiator for inline code */
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
@@ -153,9 +201,20 @@
}
@keyframes pulse-small {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Chapter Loading Overlay and Spinners */
@@ -246,29 +305,48 @@
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes scaleIn {
from { transform: scale(0.9) translateY(10px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
from {
transform: scale(0.9) translateY(10px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* MOBILE READER UI OVERRIDES */
@media (max-width: 768px) {
.reader-canvas {
padding-top: 54px !important;
padding-bottom: 80px !important; /* Ensure content is clear of bottom toolbar */
padding-bottom: 80px !important;
/* Ensure content is clear of bottom toolbar */
}
.reader-flow-container {
padding-bottom: 4rem; /* Safe breathing room */
padding-bottom: 4rem;
/* Safe breathing room */
}
}
@@ -338,7 +416,20 @@
}
.theme-light .nexus-mobile-chapter-title {
color: #1a1a1a;
color: #292524;
}
.theme-light .nexus-mobile-escape-btn {
color: #78716c;
}
.theme-light .nexus-mobile-escape-btn:hover {
color: #10b981;
background-color: rgba(16, 185, 129, 0.05);
}
.theme-light .nexus-mobile-escape-btn:active {
background-color: rgba(16, 185, 129, 0.08);
}
.nexus-chapter-nav-btn {
@@ -1,64 +1,99 @@
.reader-footer {
position: relative;
height: 50px;
background: #F9F9F9;
border-top: 1px solid rgba(0, 0, 0, 0.08);
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: min(600px, 90%);
height: 54px;
background: rgba(24, 24, 27, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 9999px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
padding: 0 1.5rem;
z-index: 10;
flex-shrink: 0;
z-index: 100;
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
}
.theme-light .reader-footer {
background: rgba(254, 254, 254, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.02);
}
.footer-content {
display: flex;
align-items: center;
width: 100%;
gap: 1.5rem;
gap: 1rem;
justify-content: space-between;
}
.navigation-controls {
display: grid;
grid-template-columns: 32px 1fr 32px;
display: flex;
align-items: center;
gap: 0.75rem;
width: 260px;
flex-shrink: 0;
flex: 1;
min-width: 0;
}
.nav-btn {
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: #333;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, transform 0.2s ease-in-out;
color: #a1a1aa; /* Zinc-400 default contrast */
}
.nav-btn:hover:not(:disabled) {
background: #f0f0f0;
transform: translateY(-1px);
.nav-btn:hover:not(:disabled),
.nav-btn:focus:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: var(--nexus-neon, #00ff99);
color: var(--nexus-neon, #00ff99); /* Brand neon green hover/focus signal */
transform: scale(1.05);
outline: none;
}
.nav-btn:disabled {
opacity: 0.3;
opacity: 0.25;
cursor: not-allowed;
}
.theme-light .nav-btn {
background: rgba(0, 0, 0, 0.02);
border-color: rgba(0, 0, 0, 0.08);
color: #78716c; /* Warm stone-500 */
}
.theme-light .nav-btn:hover:not(:disabled),
.theme-light .nav-btn:focus:not(:disabled) {
background: rgba(16, 185, 129, 0.05);
border-color: #10b981;
color: #10b981;
outline: none;
}
.chapter-info {
display: flex;
flex-direction: column;
align-items: center;
align-items: flex-start;
justify-content: center;
min-width: 0;
overflow: hidden;
color: #333;
flex: 1;
color: #e2e8f0; /* Slate-200 for clean high readability */
}
.theme-light .chapter-info {
color: #292524; /* Warm charcoal for legibility */
}
.chapter-title {
@@ -68,38 +103,55 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
line-height: 1.2;
}
.chapter-count {
opacity: 0.5;
font-size: 0.75rem;
color: #a1a1aa; /* Zinc-400 for secondary info clarity */
font-size: 0.7rem;
}
.theme-light .chapter-count {
color: #78716c; /* Warm stone-500 secondary info */
}
.progress-container {
flex: 1;
height: 6px;
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
width: 80px;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
overflow: hidden;
margin: 0 1rem;
margin: 0 0.25rem;
flex-shrink: 0;
}
.theme-light .progress-container {
background: rgba(0, 0, 0, 0.08);
}
.progress-bar {
height: 100%;
background: #2ECC71;
border-radius: 3px;
background: var(--nexus-neon, #00ff99);
border-radius: 2px;
transition: width 0.3s ease;
}
.theme-light .progress-bar {
background: #10b981;
}
.meta-info {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.75rem;
color: #888;
gap: 0.75rem;
font-size: 0.7rem;
color: #a1a1aa;
flex-shrink: 0;
font-family: monospace;
}
.theme-light .meta-info {
color: #78716c;
}
.battery {
@@ -107,3 +159,10 @@
align-items: center;
gap: 0.3rem;
}
/* RWD constraint: floating toolbar visible ONLY on desktop (min-width: 1024px) */
@media (max-width: 1023px) {
.reader-footer {
display: none !important;
}
}
@@ -16,10 +16,12 @@
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
@inject IThemeService ThemeService
@inject KnowledgeCoordinator Coordinator
@implements IAsyncDisposable
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}")">
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}") @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
<div class="reader-pane">
<main>
@Body
@@ -62,7 +64,32 @@
<span class="panel-title">Contextual Intelligence Panel</span>
</div>
<div class="panel-body">
@if (_selectedNode != null)
@if (Coordinator.IsLoadingSelectionSummary)
{
<div class="skeleton-container">
<div class="skeleton-line title"></div>
<div class="skeleton-line w-90"></div>
<div class="skeleton-line w-80"></div>
<div class="skeleton-line w-70"></div>
<div class="skeleton-line w-60"></div>
</div>
}
else if (!string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
<div class="node-details">
<div class="node-header-section">
<div class="summary-badge-row">
<span class="node-group-badge current">PODSUMOWANIE</span>
<button class="clear-summary-btn" @onclick="ClearSelectionSummary" title="Wyczyść podsumowanie">×</button>
</div>
<h3 class="node-label">Zaznaczony Fragment</h3>
</div>
<div class="detail-section summary-section">
<p class="node-summary">@Coordinator.SelectionSummary</p>
</div>
</div>
}
else if (_selectedNode != null)
{
<div class="node-details">
<div class="node-header-section">
@@ -165,7 +192,32 @@
{
<div class="contextual-intelligence-panel">
<div class="panel-body">
@if (_selectedNode != null)
@if (Coordinator.IsLoadingSelectionSummary)
{
<div class="skeleton-container">
<div class="skeleton-line title"></div>
<div class="skeleton-line w-90"></div>
<div class="skeleton-line w-80"></div>
<div class="skeleton-line w-70"></div>
<div class="skeleton-line w-60"></div>
</div>
}
else if (!string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
<div class="node-details">
<div class="node-header-section">
<div class="summary-badge-row">
<span class="node-group-badge current">PODSUMOWANIE</span>
<button class="clear-summary-btn" @onclick="ClearSelectionSummary" title="Wyczyść podsumowanie">×</button>
</div>
<h3 class="node-label">Zaznaczony Fragment</h3>
</div>
<div class="detail-section summary-section">
<p class="node-summary">@Coordinator.SelectionSummary</p>
</div>
</div>
}
else if (_selectedNode != null)
{
<div class="node-details">
<div class="node-header-section">
@@ -291,6 +343,8 @@
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
ThemeService.OnThemeChanged += HandleThemeChangedAsync;
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
var context = PlatformService.GetDeviceContext();
if (context.IsSuccess)
@@ -305,6 +359,8 @@
}
}
private async Task HandleThemeChangedAsync() => await InvokeAsync(StateHasChanged);
private void SetActiveTab(SidebarTab tab)
{
_activeTab = tab;
@@ -329,6 +385,11 @@
StateHasChanged();
}
private async Task ClearSelectionSummary()
{
await Coordinator.ClearSelectionSummaryAsync();
}
private async Task HandleScrollPercentChanged(int percent)
{
_scrollPercentage = percent;
@@ -349,13 +410,25 @@
{
if (_isMobile)
{
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
_activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Knowledge;
}
OpenAssistant();
}
else
{
_activeMobileTab = MobileReaderTab.Concepts;
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
_activeTab = SidebarTab.Knowledge;
}
else
{
_activeTab = SidebarTab.Quiz;
}
}
await InvokeAsync(StateHasChanged);
}
@@ -445,6 +518,8 @@
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
try
{
@@ -4,18 +4,20 @@
width: 100vw;
height: 100vh;
overflow: hidden;
background: #121212;
background: var(--nexus-bg);
}
.reader-pane {
background: #F9F9F9;
grid-column: 1;
background: var(--nexus-bg);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
z-index: 5;
height: 100vh;
transition: background 0.2s ease, color 0.2s ease;
}
main {
@@ -27,30 +29,65 @@ main {
}
.intelligence-sidebar {
grid-column: 3;
display: grid;
grid-template-columns: 50px 1fr;
width: 100%; /* controlled by grid */
width: 100%;
/* controlled by grid */
height: 100%;
background: #0d0d0d;
background: var(--nexus-card);
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
border-left: 1px solid rgba(255, 255, 255, 0.1);
border-left: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
z-index: 10;
transition: background 0.2s ease, border-color 0.2s ease;
}
.resizer {
width: 4px;
grid-column: 2;
width: 12px;
cursor: col-resize;
background: rgba(255, 255, 255, 0.02);
transition: background 0.2s, width 0.2s;
background: transparent;
z-index: 20;
border-left: 1px solid rgba(255, 255, 255, 0.05);
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.resizer:hover, .app-container.is-resizing .resizer {
.resizer::before {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 0;
width: 1px;
height: 100%;
background: rgba(255, 255, 255, 0.05);
transition: background 0.2s ease;
}
.resizer::after {
content: '';
width: 4px;
height: 60px;
background: rgba(255, 255, 255, 0.15);
border-radius: 9999px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.resizer:hover::before,
.app-container.is-resizing .resizer::before {
background: rgba(255, 255, 255, 0.15);
}
.resizer:hover::after,
.app-container.is-resizing .resizer::after {
background: var(--nexus-neon);
width: 6px;
box-shadow: 0 0 10px var(--nexus-neon);
height: 80px;
box-shadow: 0 0 12px var(--nexus-neon);
}
.app-container.is-resizing {
@@ -63,6 +100,7 @@ main {
}
.app-container.focus-mode-active .intelligence-sidebar {
grid-column: 3;
grid-template-columns: 50px 0px;
}
@@ -94,7 +132,7 @@ main {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-family: var(--nexus-font-sans);
font-size: 0.9rem;
color: #fff;
color: var(--nexus-text);
flex-shrink: 0;
}
@@ -149,9 +187,20 @@ main {
}
@keyframes quiz-pulse {
0% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
50% { filter: drop-shadow(0 0 10px var(--nexus-neon)); transform: scale(1.1); }
100% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
0% {
filter: drop-shadow(0 0 2px var(--nexus-neon));
transform: scale(1);
}
50% {
filter: drop-shadow(0 0 10px var(--nexus-neon));
transform: scale(1.1);
}
100% {
filter: drop-shadow(0 0 2px var(--nexus-neon));
transform: scale(1);
}
}
/* Contextual Intelligence Panel Layout */
@@ -226,9 +275,20 @@ main {
}
@keyframes glow-pulse {
0% { transform: scale(0.9); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(0.9); opacity: 0.5; }
0% {
transform: scale(0.9);
opacity: 0.5;
}
50% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(0.9);
opacity: 0.5;
}
}
.placeholder-text {
@@ -245,8 +305,15 @@ main {
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.node-header-section {
@@ -432,9 +499,20 @@ main {
}
@keyframes quiz-pulse-glow {
0% { border-color: rgba(0, 240, 255, 0.3); box-shadow: 0 0 5px rgba(0, 240, 255, 0.1); }
50% { border-color: var(--nexus-neon, #00f0ff); box-shadow: 0 0 25px rgba(0, 240, 255, 0.3); }
100% { border-color: rgba(0, 240, 255, 0.3); box-shadow: 0 0 5px rgba(0, 240, 255, 0.1); }
0% {
border-color: rgba(0, 240, 255, 0.3);
box-shadow: 0 0 5px rgba(0, 240, 255, 0.1);
}
50% {
border-color: var(--nexus-neon, #00f0ff);
box-shadow: 0 0 25px rgba(0, 240, 255, 0.3);
}
100% {
border-color: rgba(0, 240, 255, 0.3);
box-shadow: 0 0 5px rgba(0, 240, 255, 0.1);
}
}
/* Quiz Navigation Header */
@@ -479,7 +557,8 @@ main {
.platform-mobile .reader-pane {
width: 100vw !important;
height: 100vh !important; /* full viewport height */
height: 100vh !important;
/* full viewport height */
position: absolute;
top: 0;
left: 0;
@@ -506,9 +585,11 @@ main {
}
.platform-mobile .nexus-mobile-reader-tabs {
display: none; /* Keep hidden by default */
display: none;
/* Keep hidden by default */
width: 100vw;
height: 100vh; /* full viewport height */
height: 100vh;
/* full viewport height */
position: absolute;
top: 0;
left: 0;
@@ -519,7 +600,8 @@ main {
.app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs,
.app-container.platform-mobile.active-mobile-tab-concepts .nexus-mobile-reader-tabs {
display: block; /* Show only when graph or concepts tabs are active */
display: block;
/* Show only when graph or concepts tabs are active */
}
.nexus-mobile-tab-content {
@@ -542,6 +624,7 @@ main {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -621,9 +704,18 @@ main {
}
@keyframes quiz-pulse-btn-anim {
0% { color: rgba(255, 255, 255, 0.5); }
50% { color: #f43f5e; text-shadow: 0 0 8px rgba(244, 63, 94, 0.6); }
100% { color: rgba(255, 255, 255, 0.5); }
0% {
color: rgba(255, 255, 255, 0.5);
}
50% {
color: #f43f5e;
text-shadow: 0 0 8px rgba(244, 63, 94, 0.6);
}
100% {
color: rgba(255, 255, 255, 0.5);
}
}
.mobile-insight-body {
@@ -648,3 +740,279 @@ main {
}
/* Obsolescence managed: consolidated mobile toolbar and sheet styled inside respective components */
/* Theme-specific Overrides for Light Mode */
.app-container.theme-light .intelligence-sidebar {
background: #f4f1ea;
border-left: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: -10px 0 30px rgba(139, 130, 115, 0.05);
}
.app-container.theme-light .resizer {
background: transparent;
}
.app-container.theme-light .resizer::before {
background: rgba(0, 0, 0, 0.08);
}
.app-container.theme-light .resizer::after {
background: rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 8px rgba(139, 130, 115, 0.12);
}
.app-container.theme-light .resizer:hover::before,
.app-container.theme-light.is-resizing .resizer::before {
background: rgba(0, 0, 0, 0.15);
}
.app-container.theme-light .resizer:hover::after,
.app-container.theme-light.is-resizing .resizer::after {
background: #10b981;
width: 6px;
height: 80px;
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
}
.app-container.theme-light .intelligence-header {
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
color: #292524;
}
.app-container.theme-light .close-btn {
color: #878378;
}
.app-container.theme-light .close-btn:hover {
color: #292524;
}
.app-container.theme-light .visual-workspace {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.app-container.theme-light .contextual-intelligence-panel {
background: #f4f1ea;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.app-container.theme-light .panel-header {
background: rgba(0, 0, 0, 0.01);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.app-container.theme-light .panel-title {
color: #78716c;
}
.app-container.theme-light .no-node-selected {
color: #878378;
}
.app-container.theme-light .placeholder-glow {
background: radial-gradient(circle, rgba(16, 185, 129, 0.15) 0%, transparent 70%);
}
.app-container.theme-light .node-header-section {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.app-container.theme-light .node-label {
color: #292524;
}
.app-container.theme-light .node-details .section-title {
color: #78716c;
}
.app-container.theme-light .neon-sub-header {
border-left: 2px solid #10b981;
text-shadow: none;
}
.app-container.theme-light .node-description {
color: #292524;
}
.app-container.theme-light .node-summary {
color: #44403c;
background: rgba(0, 0, 0, 0.02);
border-left: 2px solid rgba(0, 0, 0, 0.1);
}
.app-container.theme-light .key-term-item {
color: #292524;
}
.app-container.theme-light .term-bullet {
color: #10b981;
filter: none;
}
.app-container.theme-light .sidebar-footer {
background: #f4f1ea;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.app-container.theme-light .open-quiz-btn {
background: rgba(16, 185, 129, 0.03);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #10b981;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.05);
}
.app-container.theme-light .open-quiz-btn:hover {
background: rgba(16, 185, 129, 0.08);
border-color: #10b981;
color: #10b981;
box-shadow: 0 0 20px rgba(16, 185, 129, 0.15);
}
.app-container.theme-light .quiz-pulse-btn {
animation: quiz-pulse-btn-light 2s infinite ease-in-out;
}
@keyframes quiz-pulse-btn-light {
0% {
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 0 5px rgba(16, 185, 129, 0.05);
}
50% {
border-color: #10b981;
box-shadow: 0 0 20px rgba(16, 185, 129, 0.2);
}
100% {
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 0 5px rgba(16, 185, 129, 0.05);
}
}
.app-container.theme-light .quiz-nav {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.01);
}
.app-container.theme-light .back-to-graph-btn {
color: #78716c;
}
.app-container.theme-light .back-to-graph-btn:hover {
color: #10b981;
background: rgba(0, 0, 0, 0.03);
}
.app-container.theme-light .mobile-insight-body {
background: #f4f1ea;
}
.app-container.theme-light .mobile-insight-header {
background: #f4f1ea;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.app-container.theme-light .mobile-insight-nav {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.app-container.theme-light .mobile-insight-nav-btn {
color: #78716c;
}
.app-container.theme-light .mobile-insight-nav-btn.active {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
}
.app-container.theme-light .skeleton-line {
background: linear-gradient(90deg, rgba(0, 0, 0, 0.03) 25%, rgba(0, 0, 0, 0.08) 50%, rgba(0, 0, 0, 0.03) 75%);
}
.app-container.theme-light .clear-summary-btn {
color: rgba(0, 0, 0, 0.4);
}
.app-container.theme-light .clear-summary-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
/* Skeleton Loader for Selection Summary */
.skeleton-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0;
}
.skeleton-line {
height: 0.75rem;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite linear;
border-radius: 4px;
}
.skeleton-line.title {
height: 1.25rem;
width: 60%;
margin-bottom: 0.5rem;
}
.skeleton-line.w-90 {
width: 90%;
}
.skeleton-line.w-80 {
width: 80%;
}
.skeleton-line.w-70 {
width: 70%;
}
.skeleton-line.w-60 {
width: 60%;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.summary-badge-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
/* Clear Summary Button styling */
.clear-summary-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.clear-summary-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
@@ -15,7 +15,7 @@ public enum MobileReaderTab
/// <summary>
/// Screen coordinates for text selection popup positioning.
/// </summary>
public record SelectionCoordinates(double Top, double Left, double Width);
public record SelectionCoordinates(double Top, double Left, double Width, double Height, double Bottom, double ViewportWidth);
/// <summary>
/// Represents a message in the KM-RAG global and mobile intelligence chat threads.
@@ -22,12 +22,21 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
public string CurrentFullPageContent { get; private set; } = string.Empty;
public bool IsLoadingSelectionSummary { get; private set; }
public string? SelectionSummary { get; private set; }
public string? SelectedTextContext { get; private set; }
/// <summary>
/// Raised when the knowledge graph has been updated with new data.
/// Subscribers must return a Task to enable proper async handling.
/// </summary>
public event Func<GraphDataDto, Task>? OnGraphUpdated;
/// <summary>
/// Raised when the selection summary state has changed (loading started, finished, or cleared).
/// </summary>
public event Func<Task>? OnSelectionSummaryStateChanged;
public KnowledgeCoordinator(
IKnowledgeService knowledgeService,
IKnowledgeGraphService graphService,
@@ -205,6 +214,51 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
}
}
public async Task StartSelectionSummaryAsync(string text, string tenantId = "global")
{
if (string.IsNullOrWhiteSpace(text)) return;
IsLoadingSelectionSummary = true;
SelectionSummary = null;
SelectedTextContext = text;
if (OnSelectionSummaryStateChanged != null)
{
await OnSelectionSummaryStateChanged.Invoke();
}
try
{
var result = await RequestSummaryAndQuizAsync(text, tenantId);
if (result.IsSuccess)
mjasin marked this conversation as resolved
Review

🟡 Design/Architecture: If RequestSummaryAndQuizAsync fails, the background task completes, but the loading state is cleared and no error feedback is provided to the UI or logged.

Consider exposing an error state or logging the failure to improve user feedback.

Actionable suggestion:

            var result = await RequestSummaryAndQuizAsync(text, tenantId);
            if (result.IsSuccess)
            {
                SelectionSummary = result.Value.Summary;
            }
            else
            {
                _logger.LogWarning("[KnowledgeCoordinator] Selection summary request failed: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message)));
                // optionally set an error property to show in UI
            }
🟡 Design/Architecture: If `RequestSummaryAndQuizAsync` fails, the background task completes, but the loading state is cleared and no error feedback is provided to the UI or logged. Consider exposing an error state or logging the failure to improve user feedback. Actionable suggestion: ```csharp var result = await RequestSummaryAndQuizAsync(text, tenantId); if (result.IsSuccess) { SelectionSummary = result.Value.Summary; } else { _logger.LogWarning("[KnowledgeCoordinator] Selection summary request failed: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message))); // optionally set an error property to show in UI } ```
{
SelectionSummary = result.Value.Summary;
}
else
{
_logger.LogWarning("[KnowledgeCoordinator] Selection summary request failed: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message)));
}
}
finally
{
IsLoadingSelectionSummary = false;
if (OnSelectionSummaryStateChanged != null)
{
await OnSelectionSummaryStateChanged.Invoke();
}
}
}
public async Task ClearSelectionSummaryAsync()
{
SelectionSummary = null;
SelectedTextContext = null;
IsLoadingSelectionSummary = false;
if (OnSelectionSummaryStateChanged != null)
{
await OnSelectionSummaryStateChanged.Invoke();
}
}
public async Task ClearAsync()
{
CancelAndDisposeCts(ref _graphCts);
@@ -213,6 +267,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
CurrentFullPageContent = string.Empty;
await _graphService.Clear();
await _quizService.SetQuiz(null, null);
await ClearSelectionSummaryAsync();
}
public void Dispose()
+214 -29
View File
@@ -3,37 +3,83 @@
:root {
--nexus-neon: #00ff99;
--nexus-neon-glow: rgba(0, 255, 153, 0.3);
--nexus-bg: #121212;
--nexus-card: #1a1a1a;
--nexus-bg: #121214;
--nexus-card: #1a1a1e;
--nexus-text: #ffffff;
--nexus-paper: #F9F9F9;
--nexus-font-sans: 'Inter', sans-serif;
--nexus-font-serif: 'Merriweather', serif;
/* Global Semantic Theme Mapping */
--nexus-primary: var(--nexus-neon);
--nexus-primary-glow: var(--nexus-neon-glow);
--nexus-primary-hover: #00e688;
/* Global Selection Style Override */
--nexus-selection: rgba(0, 255, 153, 0.25);
/* Standard Layout Tokens */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
/* Graph Nodes Theme Custom Properties (Dark Mode) */
--nexus-graph-bg: radial-gradient(circle, #1a1a1a 0%, #121212 100%);
--nexus-graph-link-secondary: rgba(255, 255, 255, 0.2);
--nexus-graph-link-default: rgba(255, 255, 255, 0.1);
/* Safe Area Insets with fallbacks */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--nexus-node-pill-bg: rgba(20, 20, 20, 0.95);
/* Transitions */
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--nexus-node-rule: #ff4646;
--nexus-node-rule-bg: rgba(255, 70, 70, 0.1);
--nexus-node-rule-text: #ff8b8b;
--nexus-node-definition: #ffb03a;
--nexus-node-definition-bg: rgba(255, 176, 58, 0.1);
--nexus-node-definition-text: #ffd18c;
--nexus-node-table: #d946ef;
--nexus-node-table-bg: rgba(217, 70, 239, 0.1);
--nexus-node-table-text: #f5d0fe;
--nexus-node-section: #3b82f6;
--nexus-node-section-bg: rgba(59, 130, 246, 0.1);
--nexus-node-section-text: #93c5fd;
--nexus-node-bridge: #06b6d4;
--nexus-node-bridge-bg: rgba(6, 182, 212, 0.1);
--nexus-node-bridge-text: #67e8f9;
--nexus-node-current: var(--nexus-neon);
--nexus-node-current-bg: rgba(0, 255, 153, 0.15);
--nexus-node-current-text: #ffffff;
--nexus-node-concept: #00d2c4;
--nexus-node-concept-bg: rgba(0, 210, 196, 0.05);
--nexus-node-concept-text: #e0e0e0;
}
::selection {
background-color: var(--nexus-selection);
color: inherit;
}
/* Global Semantic Theme Mapping */
--nexus-primary: var(--nexus-neon);
--nexus-primary-glow: var(--nexus-neon-glow);
--nexus-primary-hover: #00e688;
/* Standard Layout Tokens */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
/* Safe Area Insets with fallbacks */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
/* Transitions */
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Global Glassmorphism with Fallback */
.glass-panel {
background: rgba(20, 20, 20, 0.85); /* Darker fallback for readability */
background: rgba(20, 20, 20, 0.85);
/* Darker fallback for readability */
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: var(--radius-xl);
padding: 1.5rem;
@@ -61,32 +107,157 @@
border: none;
text-decoration: none;
}
.btn-nexus-primary {
background: var(--nexus-neon);
color: #000000;
}
.btn-nexus-secondary {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.btn-nexus:hover {
transform: translateY(-2px);
filter: brightness(1.1);
}
.btn-nexus-primary:hover {
box-shadow: 0 4px 15px var(--nexus-primary-glow);
}
.btn-nexus-secondary:hover {
box-shadow: 0 4px 15px rgba(255, 255, 255, 0.05);
}
.theme-light {
--nexus-bg: var(--nexus-paper);
--nexus-bg: #f4f1ea;
--nexus-card: #ffffff;
--nexus-text: #121212;
--nexus-text: #2d2a26;
--nexus-selection: rgba(16, 185, 129, 0.18);
/* Graph Nodes Theme Custom Properties (Light Mode) */
--nexus-graph-bg: radial-gradient(circle, #ffffff 0%, #e8e4da 100%);
--nexus-graph-link-secondary: rgba(0, 0, 0, 0.15);
--nexus-graph-link-default: rgba(0, 0, 0, 0.08);
--nexus-node-pill-bg: #fbfafa;
--nexus-node-rule: #dc2626;
--nexus-node-rule-bg: rgba(220, 38, 38, 0.05);
--nexus-node-rule-text: #991b1b;
--nexus-node-definition: #d97706;
--nexus-node-definition-bg: rgba(217, 119, 6, 0.05);
--nexus-node-definition-text: #92400e;
--nexus-node-table: #c084fc;
--nexus-node-table-bg: rgba(192, 132, 252, 0.05);
--nexus-node-table-text: #6b21a8;
--nexus-node-section: #2563eb;
--nexus-node-section-bg: rgba(37, 99, 235, 0.05);
--nexus-node-section-text: #1e3a8a;
--nexus-node-bridge: #0891b2;
--nexus-node-bridge-bg: rgba(8, 145, 178, 0.05);
--nexus-node-bridge-text: #155e75;
--nexus-node-current: #10b981;
--nexus-node-current-bg: rgba(16, 185, 129, 0.08);
--nexus-node-current-text: #064e3b;
--nexus-node-concept: #0d9488;
--nexus-node-concept-bg: rgba(13, 148, 136, 0.03);
--nexus-node-concept-text: #115e59;
--nexus-accent: #10b981;
}
.theme-light .knowledge-graph-container svg {
background: radial-gradient(circle, #ffffff 0%, #e8e4da 100%) !important;
}
mjasin marked this conversation as resolved
Review

🟡 Design/Architecture: Global CSS overrides for light-mode component colors (like .theme-light .intelligence-toolbar) violate the architectural separation of concerns.

Component-specific styling should live inside their respective scoped stylesheets (e.g., IntelligenceToolbar.razor.css) rather than the global app.css. Using !important here also creates styling conflicts (e.g., the scoped stylesheet has a background of #ffffff, but this global rule overrides it with #f5f5f4).

Please move these overrides into IntelligenceToolbar.razor.css.

🟡 Design/Architecture: Global CSS overrides for light-mode component colors (like `.theme-light .intelligence-toolbar`) violate the architectural separation of concerns. Component-specific styling should live inside their respective scoped stylesheets (e.g., `IntelligenceToolbar.razor.css`) rather than the global `app.css`. Using `!important` here also creates styling conflicts (e.g., the scoped stylesheet has a background of `#ffffff`, but this global rule overrides it with `#f5f5f4`). Please move these overrides into `IntelligenceToolbar.razor.css`.
.theme-light .graph-controls {
background: rgba(254, 254, 253, 0.4) !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04) !important;
}
.theme-light .zoom-btn {
background: rgba(0, 0, 0, 0.02) !important;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
color: #78716c !important;
}
.theme-light .zoom-btn:hover {
background: rgba(16, 185, 129, 0.05) !important;
color: #10b981 !important;
border-color: #10b981 !important;
}
.theme-light .loading-state {
color: #292524 !important;
background: rgba(254, 254, 254, 0.85) !important;
border: 1px solid rgba(0, 0, 0, 0.05) !important;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.05) !important;
}
.theme-light .neon-pulse {
color: #10b981 !important;
filter: none !important;
animation: robot-pulse-light 2s infinite ease-in-out !important;
}
.theme-light .scan-line {
background: #10b981 !important;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5) !important;
}
.theme-light .nexus-node-active {
stroke: #10b981 !important;
filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.2)) !important;
}
@keyframes robot-pulse-light {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.theme-light ::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
}
.theme-light ::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
.theme-light .glass-panel {
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.05);
}
@supports (backdrop-filter: blur(10px)) {
.theme-light .glass-panel {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
}
}
.theme-light .btn-nexus-secondary {
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.08);
color: #292524;
}
.theme-light .btn-nexus-secondary:hover {
background: rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
* {
@@ -133,14 +304,14 @@ body {
/* Platform Specific Tweaks */
.platform-mobile .nexus-button {
.platform-mobile .nexus-btn {
min-height: var(--touch-target-size);
min-width: var(--touch-target-size);
font-size: 1.1rem;
padding: 12px 24px;
}
.platform-desktop .nexus-button {
.platform-desktop .nexus-btn {
min-height: 36px;
font-size: 0.9rem;
padding: 8px 16px;
@@ -164,7 +335,8 @@ h1:focus {
}
/* Preloader Styles */
#app-preloader, .app-preloader {
#app-preloader,
.app-preloader {
position: fixed;
top: 0;
left: 0;
1
@@ -206,12 +378,25 @@ h1:focus {
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.95); }
}
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.95);
}
}
@@ -40,70 +40,70 @@ const getCategoryStyle = d => {
// 1. Rule (red/coral)
if (type === 'rule') {
return {
color: '#ff4646',
fill: 'rgba(255, 70, 70, 0.1)',
color: 'var(--nexus-node-rule, #ff4646)',
fill: 'var(--nexus-node-rule-bg, rgba(255, 70, 70, 0.1))',
opacity: 0.8,
glowKey: 'rule',
textColor: '#ff8b8b'
textColor: 'var(--nexus-node-rule-text, #ff8b8b)'
};
}
// 2. Definition (gold/amber)
if (type === 'definition') {
return {
color: '#ffb03a',
fill: 'rgba(255, 176, 58, 0.1)',
color: 'var(--nexus-node-definition, #ffb03a)',
fill: 'var(--nexus-node-definition-bg, rgba(255, 176, 58, 0.1))',
opacity: 0.8,
glowKey: 'definition',
textColor: '#ffd18c'
textColor: 'var(--nexus-node-definition-text, #ffd18c)'
};
}
// 3. Table (purple/magenta)
if (type === 'table') {
return {
color: '#d946ef',
fill: 'rgba(217, 70, 239, 0.1)',
color: 'var(--nexus-node-table, #d946ef)',
fill: 'var(--nexus-node-table-bg, rgba(217, 70, 239, 0.1))',
opacity: 0.8,
glowKey: 'table',
textColor: '#f5d0fe'
textColor: 'var(--nexus-node-table-text, #f5d0fe)'
};
}
// 4. Section (blue/indigo)
if (type === 'section') {
return {
color: '#3b82f6',
fill: 'rgba(59, 130, 246, 0.1)',
color: 'var(--nexus-node-section, #3b82f6)',
fill: 'var(--nexus-node-section-bg, rgba(59, 130, 246, 0.1))',
opacity: 0.8,
glowKey: 'section',
textColor: '#93c5fd'
textColor: 'var(--nexus-node-section-text, #93c5fd)'
};
}
// 5. Bridge (cyan/comparison)
if (group === 'bridge') {
return {
color: '#06b6d4',
fill: 'rgba(6, 182, 212, 0.1)',
color: 'var(--nexus-node-bridge, #06b6d4)',
fill: 'var(--nexus-node-bridge-bg, rgba(6, 182, 212, 0.1))',
opacity: 0.7,
glowKey: 'bridge',
textColor: '#67e8f9'
textColor: 'var(--nexus-node-bridge-text, #67e8f9)'
};
}
// 6. Current (active/focus landmark - neon green)
if (group === 'current') {
return {
color: 'var(--nexus-neon)',
fill: 'rgba(0, 255, 153, 0.15)',
color: 'var(--nexus-node-current, var(--nexus-neon))',
fill: 'var(--nexus-node-current-bg, rgba(0, 255, 153, 0.15))',
opacity: 0.9,
glowKey: 'current',
textColor: '#ffffff'
textColor: 'var(--nexus-node-current-text, #ffffff)'
};
}
// 7. Concept / Default (subtle cool steel blue/teal)
return {
color: '#00d2c4',
fill: 'rgba(0, 210, 196, 0.05)',
color: 'var(--nexus-node-concept, #00d2c4)',
fill: 'var(--nexus-node-concept-bg, rgba(0, 210, 196, 0.05))',
opacity: 0.4,
glowKey: 'concept',
textColor: '#e0e0e0'
textColor: 'var(--nexus-node-concept-text, #e0e0e0)'
};
};
@@ -132,7 +132,7 @@ const getNodeGlyph = d => {
function updateNodeAppearances() {
if (!node) return;
node.each(function(d) {
node.each(function (d) {
const g = d3.select(this);
const rect = g.select(".node-pill");
const text = g.select("text");
@@ -208,7 +208,7 @@ export function mount(containerId, data, dotNetHelper) {
.attr("viewBox", [0, 0, width, height])
.attr("width", "100%")
.attr("height", "100%")
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
.style("background", "var(--nexus-graph-bg, radial-gradient(circle, #1a1a1a 0%, #121212 100%))");
// Radial gradients for Nebula effects
const defs = svgElement.append("defs");
@@ -223,13 +223,13 @@ export function mount(containerId, data, dotNetHelper) {
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
const colors = {
'rule': '#ff4646',
'definition': '#ffb03a',
'table': '#d946ef',
'section': '#3b82f6',
'bridge': '#06b6d4',
'current': 'var(--nexus-neon)',
'concept': '#00d2c4'
'rule': 'var(--nexus-node-rule, #ff4646)',
'definition': 'var(--nexus-node-definition, #ffb03a)',
'table': 'var(--nexus-node-table, #d946ef)',
'section': 'var(--nexus-node-section, #3b82f6)',
'bridge': 'var(--nexus-node-bridge, #06b6d4)',
'current': 'var(--nexus-node-current, var(--nexus-neon))',
'concept': 'var(--nexus-node-concept, #00d2c4)'
};
Object.entries(colors).forEach(([key, color]) => {
@@ -377,9 +377,9 @@ export function updateData(data) {
enter => enter.append("path")
.attr("stroke", d => {
if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)';
if (d.type === 'Next' || d.type === 'relates_to') return 'var(--nexus-graph-link-secondary, rgba(255,255,255,0.2))';
if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
return 'rgba(255,255,255,0.1)';
return 'var(--nexus-graph-link-default, rgba(255,255,255,0.1))';
})
.attr("fill", "none")
.attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
@@ -413,7 +413,7 @@ export function updateData(data) {
g.append("rect")
.attr("class", "node-pill")
.attr("fill", "rgba(20, 20, 20, 0.95)")
.attr("fill", "var(--nexus-node-pill-bg, rgba(20, 20, 20, 0.95))")
.attr("stroke", d => getCategoryStyle(d).color)
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
@@ -1,8 +1,71 @@
export function positionToolbar() {
const toolbarElement = document.querySelector('.selection-ai-panel');
if (!toolbarElement) return;
const selection = window.getSelection();
if (selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
if (!rects || rects.length === 0) return;
const firstRect = rects[0];
const combinedRect = range.getBoundingClientRect();
// Find the canvas container (which is the positioned parent)
const canvasElement = document.querySelector('.reader-canvas');
let canvasRect = { top: 0, left: 0 };
let scrollTop = 0;
let scrollLeft = 0;
if (canvasElement) {
canvasRect = canvasElement.getBoundingClientRect();
scrollTop = canvasElement.scrollTop;
scrollLeft = canvasElement.scrollLeft;
}
const toolbarWidth = toolbarElement.offsetWidth;
const toolbarHeight = toolbarElement.offsetHeight;
// Oblicz środek zaznaczenia w poziomie
const left = (combinedRect.left - canvasRect.left) + scrollLeft + (combinedRect.width / 2) - (toolbarWidth / 2);
// Warunek brzegowy (Top Screen Fallback)
const relativeTop = firstRect.top - toolbarHeight - 14;
let top;
let below = false;
if (relativeTop < 0) {
// Pozwól wskoczyć POD zaznaczony tekst
top = (combinedRect.bottom - canvasRect.top) + scrollTop + 12;
below = true;
toolbarElement.classList.add('below');
} else {
top = (firstRect.top - canvasRect.top) + scrollTop - toolbarHeight - 14;
toolbarElement.classList.remove('below');
}
toolbarElement.style.left = `${left}px`;
toolbarElement.style.top = `${top}px`;
return {
left: left,
top: top,
below: below
};
}
let currentHandleSelection = null;
let currentMouseUpHandler = null;
let currentContainer = null;
export function initSelectionListener(dotNetHelper, container) {
if (!container) return;
console.log("[SelectionHandler] Initializing...");
// Clean up any existing listeners first
destroySelectionListener();
const handleSelection = () => {
const selection = window.getSelection();
const text = selection.toString().trim();
@@ -17,25 +80,59 @@ export function initSelectionListener(dotNetHelper, container) {
const blockNode = node.closest('[id]');
if (blockNode) {
const rect = range.getBoundingClientRect();
const rects = range.getClientRects();
const firstRect = rects && rects.length > 0 ? rects[0] : null;
const lastRect = rects && rects.length > 0 ? rects[rects.length - 1] : null;
const combinedRect = range.getBoundingClientRect();
console.log("[SelectionHandler] Selection at screen coords:", rect.top, rect.left);
const topVal = firstRect ? firstRect.top : combinedRect.top;
const bottomVal = lastRect ? lastRect.bottom : combinedRect.bottom;
console.log("[SelectionHandler] Selection coords (first/last/combined):", topVal, bottomVal, combinedRect.left);
dotNetHelper.invokeMethodAsync('HandleTextSelected',
text,
blockNode.id,
{
Top: rect.top,
Left: rect.left,
Width: rect.width
Top: topVal,
Left: combinedRect.left,
Width: combinedRect.width,
Height: combinedRect.height,
Bottom: bottomVal,
ViewportWidth: window.innerWidth
});
// Reposition the toolbar if already present
setTimeout(positionToolbar, 0);
}
} else {
mjasin marked this conversation as resolved
Review

🔴 Blocking: Registering event listeners on document globally inside initSelectionListener without a corresponding teardown mechanism causes a memory leak.

Every time ReaderCanvas is loaded or the chapter changes, a new selectionchange listener is added to document. These listeners will accumulate over time, leading to degraded performance and multiple handler invocations.

Actionable suggestion:
Track the handler function and export a teardown function:

let currentHandleSelection = null;

export function initSelectionListener(dotNetHelper, container) {
    if (!container) return;
    
    if (currentHandleSelection) {
        document.removeEventListener('selectionchange', currentHandleSelection);
    }

    const handleSelection = () => {
        // ... selection logic ...
    };
    currentHandleSelection = handleSelection;

    document.addEventListener('selectionchange', handleSelection);
    container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
}

export function destroySelectionListener() {
    if (currentHandleSelection) {
        document.removeEventListener('selectionchange', currentHandleSelection);
        currentHandleSelection = null;
    }
}
🔴 Blocking: Registering event listeners on `document` globally inside `initSelectionListener` without a corresponding teardown mechanism causes a memory leak. Every time `ReaderCanvas` is loaded or the chapter changes, a new `selectionchange` listener is added to `document`. These listeners will accumulate over time, leading to degraded performance and multiple handler invocations. Actionable suggestion: Track the handler function and export a teardown function: ```javascript let currentHandleSelection = null; export function initSelectionListener(dotNetHelper, container) { if (!container) return; if (currentHandleSelection) { document.removeEventListener('selectionchange', currentHandleSelection); } const handleSelection = () => { // ... selection logic ... }; currentHandleSelection = handleSelection; document.addEventListener('selectionchange', handleSelection); container.addEventListener('mouseup', () => setTimeout(handleSelection, 10)); } export function destroySelectionListener() { if (currentHandleSelection) { document.removeEventListener('selectionchange', currentHandleSelection); currentHandleSelection = null; } } ```
dotNetHelper.invokeMethodAsync('HandleSelectionCleared');
}
};
// Use multiple triggers for maximum reliability
document.addEventListener('selectionchange', handleSelection);
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
const mouseUpHandler = () => setTimeout(handleSelection, 10);
currentHandleSelection = handleSelection;
currentMouseUpHandler = mouseUpHandler;
currentContainer = container;
document.addEventListener('selectionchange', currentHandleSelection);
currentContainer.addEventListener('mouseup', currentMouseUpHandler);
}
export function destroySelectionListener() {
if (currentHandleSelection) {
document.removeEventListener('selectionchange', currentHandleSelection);
currentHandleSelection = null;
}
if (currentMouseUpHandler && currentContainer) {
currentContainer.removeEventListener('mouseup', currentMouseUpHandler);
currentMouseUpHandler = null;
currentContainer = null;
}
}
export function getSelectionText() {
return window.getSelection().toString();
}
+2 -1
View File
@@ -204,7 +204,8 @@ using (var scope = app.Services.CreateScope())
logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries);
}
await dbContext.Database.MigrateAsync();
mjasin marked this conversation as resolved
Review

🟢 Minor/Suggestion: The removal of automatic database migration (MigrateAsync()) at startup is a great architectural choice. It ensures Native AOT compatibility and avoids database locking contention in multi-instance production environments.

Please make sure this is documented in the developer onboarding/setup instructions so manual CLI migrations (dotnet ef database update) are run during local environment setup.

🟢 Minor/Suggestion: The removal of automatic database migration (`MigrateAsync()`) at startup is a great architectural choice. It ensures Native AOT compatibility and avoids database locking contention in multi-instance production environments. Please make sure this is documented in the developer onboarding/setup instructions so manual CLI migrations (`dotnet ef database update`) are run during local environment setup.
await DbInitializer.SeedAsync(services);
await TriggerBackgroundProcessingForUnindexedBooksAsync(services);