refactor(ui/security): centralize state management, remove eval/async-void, enforce secure configuration, and implement interactive mobile citations
This commit is contained in:
+1
-1
@@ -32,7 +32,7 @@ GOOGLE_CLIENT_SECRET=placeholder
|
|||||||
GOOGLE_AI_API_KEY=placeholder
|
GOOGLE_AI_API_KEY=placeholder
|
||||||
|
|
||||||
# === Admin Seed Password ===
|
# === Admin Seed Password ===
|
||||||
NEXUS_ADMIN_PASSWORD=aQ13EdSw2
|
NEXUS_ADMIN_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
# === Non-standard ports for auxiliary services ===
|
# === Non-standard ports for auxiliary services ===
|
||||||
QDRANT_HTTP_PORT=6343
|
QDRANT_HTTP_PORT=6343
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
|
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
|
||||||
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
|
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
|
||||||
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
|
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
|
||||||
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:-aQ13EdSw2}
|
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -16,6 +17,7 @@ public static class DbInitializer
|
|||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
||||||
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||||
|
var configuration = scope.ServiceProvider.GetService<IConfiguration>();
|
||||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -68,7 +70,10 @@ public static class DbInitializer
|
|||||||
SecurityStamp = Guid.NewGuid().ToString()
|
SecurityStamp = Guid.NewGuid().ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
var adminPassword = Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD") ?? "Admin123!";
|
var adminPassword = configuration?["Nexus:AdminPassword"]
|
||||||
|
?? configuration?["NEXUS_ADMIN_PASSWORD"]
|
||||||
|
?? Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD")
|
||||||
|
?? "Admin123!";
|
||||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
|
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
|
||||||
|
|
||||||
dbContext.Users.Add(adminUser);
|
dbContext.Users.Add(adminUser);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ public static class MauiProgram
|
|||||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
|
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
@using NexusReader.UI.Shared.Models
|
||||||
@using NexusReader.Application.DTOs.AI
|
@using NexusReader.Application.DTOs.AI
|
||||||
@inject KnowledgeCoordinator Coordinator
|
@inject KnowledgeCoordinator Coordinator
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@using NexusReader.Application.DTOs.User
|
@using NexusReader.Application.DTOs.User
|
||||||
@using NexusReader.UI.Shared.Components.Atoms
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
@using NexusReader.UI.Shared.Models
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
@namespace NexusReader.UI.Shared.Components.Organisms
|
@namespace NexusReader.UI.Shared.Components.Organisms
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
@@ -136,6 +137,34 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
@if (_selectedCitation != null)
|
||||||
|
{
|
||||||
|
<div class="citation-modal-overlay" @onclick="CloseCitationModal">
|
||||||
|
<div class="citation-modal glass-panel" @onclick:stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="book-title"><NexusIcon Name="map" Size="14" /> @_selectedCitation.SourceBook</span>
|
||||||
|
<button class="close-btn" @onclick="CloseCitationModal" aria-label="Close">
|
||||||
|
<NexusIcon Name="close" Size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (!string.IsNullOrEmpty(_selectedCitation.Author))
|
||||||
|
{
|
||||||
|
<p class="citation-author"><strong>Autor:</strong> @_selectedCitation.Author</p>
|
||||||
|
}
|
||||||
|
@if (_selectedCitation.PageNumber.HasValue)
|
||||||
|
{
|
||||||
|
<p class="citation-page"><strong>Strona:</strong> @_selectedCitation.PageNumber.Value</p>
|
||||||
|
}
|
||||||
|
<p class="citation-snippet">"@_selectedCitation.Snippet"</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-nexus" @onclick="CloseCitationModal">Zamknij</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,23 +176,7 @@
|
|||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
private string _activeBookTitle = string.Empty;
|
private string _activeBookTitle = string.Empty;
|
||||||
private List<ChatMessage> _chatMessages = new();
|
private List<ChatMessage> _chatMessages = new();
|
||||||
|
private CitationDto? _selectedCitation;
|
||||||
public class ChatMessage
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
|
||||||
public string Sender { get; set; } = string.Empty; // "User" or "AI"
|
|
||||||
public string Text { get; set; } = string.Empty;
|
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
|
||||||
public List<ResponseSegment> Segments { get; set; } = new();
|
|
||||||
public List<CitationDto> Citations { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ResponseSegment
|
|
||||||
{
|
|
||||||
public string Text { get; set; } = string.Empty;
|
|
||||||
public bool IsCitation { get; set; }
|
|
||||||
public string CitationId { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -191,7 +204,20 @@
|
|||||||
|
|
||||||
private void HandleCitationClick(string citationId)
|
private void HandleCitationClick(string citationId)
|
||||||
{
|
{
|
||||||
// For mobile, citations are simple notifications or alerts, or scroll requests
|
_selectedCitation = _chatMessages
|
||||||
|
.SelectMany(m => m.Citations)
|
||||||
|
.FirstOrDefault(c => c.CitationId.Equals(citationId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? new CitationDto
|
||||||
|
{
|
||||||
|
CitationId = citationId,
|
||||||
|
SourceBook = "Grounded Document Chunk",
|
||||||
|
Snippet = "Context snippet retrieved from vector search node."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseCitationModal()
|
||||||
|
{
|
||||||
|
_selectedCitation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AskQuestionAsync()
|
private async Task AskQuestionAsync()
|
||||||
|
|||||||
@@ -414,3 +414,132 @@
|
|||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Citation Modal Overlay & Glassmorphic Card */
|
||||||
|
.citation-modal-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
animation: fadeIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
background: rgba(20, 20, 20, 0.85);
|
||||||
|
border: 1px solid rgba(0, 240, 255, 0.25);
|
||||||
|
box-shadow: 0 0 30px rgba(0, 240, 255, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .book-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .book-title ::deep i {
|
||||||
|
color: #00F0FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .citation-author,
|
||||||
|
.citation-modal .citation-page {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .citation-author strong,
|
||||||
|
.citation-modal .citation-page strong {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .citation-snippet {
|
||||||
|
font-style: italic;
|
||||||
|
background: rgba(0, 240, 255, 0.04);
|
||||||
|
border-left: 2px solid #00F0FF;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .btn-nexus {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 240, 255, 0.2) 0%, rgba(0, 255, 153, 0.2) 100%);
|
||||||
|
border: 1px solid rgba(0, 240, 255, 0.4);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 550;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-modal .btn-nexus:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(0, 240, 255, 0.35) 0%, rgba(0, 255, 153, 0.35) 100%);
|
||||||
|
border-color: rgba(0, 240, 255, 0.6);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@using NexusReader.UI.Shared.Components.Atoms
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
@using NexusReader.UI.Shared.Models
|
||||||
@using NexusReader.Application.Utilities
|
@using NexusReader.Application.Utilities
|
||||||
@namespace NexusReader.UI.Shared.Components.Organisms
|
@namespace NexusReader.UI.Shared.Components.Organisms
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
|
@inject IReaderStateService StateService
|
||||||
|
|
||||||
<div class="nexus-unified-mobile-toolbar">
|
<div class="nexus-unified-mobile-toolbar">
|
||||||
<!-- LEFT SLOT: Progress & Section Checkpoints -->
|
<!-- LEFT SLOT: Progress & Section Checkpoints -->
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
<div class="checkpoints-list">
|
<div class="checkpoints-list">
|
||||||
@foreach (var cp in Checkpoints)
|
@foreach (var cp in Checkpoints)
|
||||||
{
|
{
|
||||||
var isCurrent = cp == InteractionService.CurrentBlockId;
|
var isCurrent = cp == StateService.CurrentBlockId;
|
||||||
<div class="checkpoint-item @(isCurrent ? "active" : "")" @onclick="() => SelectCheckpoint(cp)">
|
<div class="checkpoint-item @(isCurrent ? "active" : "")" @onclick="() => SelectCheckpoint(cp)">
|
||||||
<div class="checkpoint-indicator">
|
<div class="checkpoint-indicator">
|
||||||
<div class="indicator-dot"></div>
|
<div class="indicator-dot"></div>
|
||||||
@@ -105,12 +107,6 @@
|
|||||||
|
|
||||||
private bool IsCheckpointsOpen { get; set; }
|
private bool IsCheckpointsOpen { get; set; }
|
||||||
|
|
||||||
public enum MobileReaderTab
|
|
||||||
{
|
|
||||||
Reader,
|
|
||||||
Graph,
|
|
||||||
Concepts
|
|
||||||
}
|
|
||||||
|
|
||||||
private double GetDashOffset()
|
private double GetDashOffset()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
@using NexusReader.Application.Queries.Reader
|
@using NexusReader.Application.Queries.Reader
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
@using NexusReader.UI.Shared.Models
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@implements IDisposable
|
@implements IAsyncDisposable
|
||||||
@inject IMediator Mediator
|
@inject IMediator Mediator
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IThemeService ThemeService
|
@inject IThemeService ThemeService
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
@inject IReaderNavigationService NavigationService
|
@inject IReaderNavigationService NavigationService
|
||||||
@inject KnowledgeCoordinator Coordinator
|
@inject KnowledgeCoordinator Coordinator
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
|
@inject IReaderStateService StateService
|
||||||
@inject ISyncService SyncService
|
@inject ISyncService SyncService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject IQuizStateService QuizService
|
@inject IQuizStateService QuizService
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
private string? _currentActiveBlockId;
|
private string? _currentActiveBlockId;
|
||||||
private bool _isMobile = false;
|
private bool _isMobile = false;
|
||||||
private DotNetObjectReference<ReaderCanvas>? _selfReference;
|
private DotNetObjectReference<ReaderCanvas>? _selfReference;
|
||||||
|
private IJSObjectReference? _viewportModule;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -160,23 +163,11 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
|
||||||
_selfReference = DotNetObjectReference.Create(this);
|
_selfReference = DotNetObjectReference.Create(this);
|
||||||
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
|
var isMobileViewport = await _viewportModule.InvokeAsync<bool>("isMobileViewport");
|
||||||
await OnViewportChanged(isMobileViewport);
|
await OnViewportChanged(isMobileViewport);
|
||||||
|
await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference);
|
||||||
await JS.InvokeVoidAsync("eval", @"
|
|
||||||
window.registerCanvasViewportObserver = (dotNetHelper) => {
|
|
||||||
let currentIsMobile = window.innerWidth < 768;
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
let isMobile = window.innerWidth < 768;
|
|
||||||
if (isMobile !== currentIsMobile) {
|
|
||||||
currentIsMobile = isMobile;
|
|
||||||
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
");
|
|
||||||
await JS.InvokeVoidAsync("registerCanvasViewportObserver", _selfReference);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -226,6 +217,7 @@
|
|||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public async Task HandleScrollPercentChanged(int percent)
|
public async Task HandleScrollPercentChanged(int percent)
|
||||||
{
|
{
|
||||||
|
StateService.CurrentScrollPercentage = percent;
|
||||||
await InteractionService.NotifyScrollPercentChanged(percent);
|
await InteractionService.NotifyScrollPercentChanged(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +225,7 @@
|
|||||||
public async Task HandleBlockReached(string blockId, string content)
|
public async Task HandleBlockReached(string blockId, string content)
|
||||||
{
|
{
|
||||||
_currentActiveBlockId = blockId;
|
_currentActiveBlockId = blockId;
|
||||||
|
StateService.CurrentBlockId = blockId;
|
||||||
await InteractionService.NotifyBlockReached(blockId);
|
await InteractionService.NotifyBlockReached(blockId);
|
||||||
await Coordinator.OnBlockReachedAsync(blockId, content);
|
await Coordinator.OnBlockReachedAsync(blockId, content);
|
||||||
|
|
||||||
@@ -338,7 +331,7 @@
|
|||||||
.Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg"))
|
.Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg"))
|
||||||
.Select(b => b.Id)
|
.Select(b => b.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
InteractionService.CurrentCheckpoints = checkpoints;
|
StateService.CurrentCheckpoints = checkpoints;
|
||||||
|
|
||||||
if (_isInteractive)
|
if (_isInteractive)
|
||||||
{
|
{
|
||||||
@@ -372,7 +365,8 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
|
var module = _viewportModule ?? await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
|
||||||
|
await module.InvokeVoidAsync("scrollIntoView", id);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -395,7 +389,7 @@
|
|||||||
await InteractionService.RequestAssistant();
|
await InteractionService.RequestAssistant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged -= HandleUpdate;
|
ThemeService.OnThemeChanged -= HandleUpdate;
|
||||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||||
@@ -405,15 +399,32 @@
|
|||||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||||
InteractionService.OnTextSelected -= HandleTextSelected;
|
InteractionService.OnTextSelected -= HandleTextSelected;
|
||||||
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
||||||
_selfReference?.Dispose();
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_viewportModule != null)
|
||||||
|
{
|
||||||
|
if (_selfReference != null)
|
||||||
|
{
|
||||||
|
await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
|
||||||
|
}
|
||||||
|
await _viewportModule.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogDebug(ex, "Teardown of viewport observer module failed in ReaderCanvas disposal.");
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_scrollListenerReference != null)
|
if (_scrollListenerReference != null)
|
||||||
{
|
{
|
||||||
_ = _scrollListenerReference.DisposeAsync();
|
await _scrollListenerReference.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
|
_selfReference?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@using NexusReader.Application.Abstractions.Services
|
@using NexusReader.Application.Abstractions.Services
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
@using NexusReader.UI.Shared.Models
|
||||||
@using NexusReader.UI.Shared.Components.Molecules
|
@using NexusReader.UI.Shared.Components.Molecules
|
||||||
@using NexusReader.UI.Shared.Components.Organisms
|
@using NexusReader.UI.Shared.Components.Organisms
|
||||||
@using NexusReader.Application.Queries.Graph
|
@using NexusReader.Application.Queries.Graph
|
||||||
@@ -9,12 +10,14 @@
|
|||||||
@inject IFocusModeService FocusMode
|
@inject IFocusModeService FocusMode
|
||||||
@inject IQuizStateService QuizService
|
@inject IQuizStateService QuizService
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
|
@inject IReaderStateService StateService
|
||||||
@inject IKnowledgeGraphService GraphService
|
@inject IKnowledgeGraphService GraphService
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
|
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
|
||||||
@implements IDisposable
|
@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()}")">
|
||||||
<div class="reader-pane">
|
<div class="reader-pane">
|
||||||
@@ -225,10 +228,10 @@
|
|||||||
|
|
||||||
<MobileReaderToolbar
|
<MobileReaderToolbar
|
||||||
ScrollPercentage="@_scrollPercentage"
|
ScrollPercentage="@_scrollPercentage"
|
||||||
ActiveTab="@GetToolbarTab(_activeMobileTab)"
|
ActiveTab="@_activeMobileTab"
|
||||||
OnTabChanged="HandleMobileTabChanged"
|
OnTabChanged="SetMobileTab"
|
||||||
OnAssistantClick="OpenAssistant"
|
OnAssistantClick="OpenAssistant"
|
||||||
Checkpoints="@InteractionService.CurrentCheckpoints" />
|
Checkpoints="@StateService.CurrentCheckpoints" />
|
||||||
|
|
||||||
<GlobalIntelligence IsOpen="@_isAssistantOpen" OnClose="CloseAssistant" />
|
<GlobalIntelligence IsOpen="@_isAssistantOpen" OnClose="CloseAssistant" />
|
||||||
}
|
}
|
||||||
@@ -255,25 +258,29 @@
|
|||||||
Quiz
|
Quiz
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum MobileReaderTab
|
|
||||||
{
|
|
||||||
Reader,
|
|
||||||
Graph,
|
|
||||||
Concepts
|
|
||||||
}
|
|
||||||
|
|
||||||
private SidebarTab _activeTab = SidebarTab.Knowledge;
|
private SidebarTab _activeTab = SidebarTab.Knowledge;
|
||||||
private MobileReaderTab _activeMobileTab = MobileReaderTab.Reader;
|
|
||||||
private string? _selectedNodeId;
|
private string? _selectedNodeId;
|
||||||
private GraphNodeDto? _selectedNode;
|
private GraphNodeDto? _selectedNode;
|
||||||
|
|
||||||
private string _platformClass = "platform-desktop";
|
private string _platformClass = "platform-desktop";
|
||||||
private bool _isMobile = false;
|
private bool _isMobile = false;
|
||||||
private DotNetObjectReference<ReaderLayout>? _selfReference;
|
private DotNetObjectReference<ReaderLayout>? _selfReference;
|
||||||
|
private IJSObjectReference? _viewportModule;
|
||||||
|
|
||||||
private int _scrollPercentage;
|
|
||||||
private bool _isAssistantOpen;
|
private bool _isAssistantOpen;
|
||||||
|
|
||||||
|
private int _scrollPercentage
|
||||||
|
{
|
||||||
|
get => StateService.CurrentScrollPercentage;
|
||||||
|
set => StateService.CurrentScrollPercentage = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MobileReaderTab _activeMobileTab
|
||||||
|
{
|
||||||
|
get => StateService.ActiveTab;
|
||||||
|
set => StateService.ActiveTab = value;
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||||
@@ -310,29 +317,6 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private MobileReaderToolbar.MobileReaderTab GetToolbarTab(MobileReaderTab layoutTab)
|
|
||||||
{
|
|
||||||
return layoutTab switch
|
|
||||||
{
|
|
||||||
MobileReaderTab.Reader => MobileReaderToolbar.MobileReaderTab.Reader,
|
|
||||||
MobileReaderTab.Graph => MobileReaderToolbar.MobileReaderTab.Graph,
|
|
||||||
MobileReaderTab.Concepts => MobileReaderToolbar.MobileReaderTab.Concepts,
|
|
||||||
_ => MobileReaderToolbar.MobileReaderTab.Reader
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleMobileTabChanged(MobileReaderToolbar.MobileReaderTab toolbarTab)
|
|
||||||
{
|
|
||||||
_activeMobileTab = toolbarTab switch
|
|
||||||
{
|
|
||||||
MobileReaderToolbar.MobileReaderTab.Reader => MobileReaderTab.Reader,
|
|
||||||
MobileReaderToolbar.MobileReaderTab.Graph => MobileReaderTab.Graph,
|
|
||||||
MobileReaderToolbar.MobileReaderTab.Concepts => MobileReaderTab.Concepts,
|
|
||||||
_ => MobileReaderTab.Reader
|
|
||||||
};
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenAssistant()
|
private void OpenAssistant()
|
||||||
{
|
{
|
||||||
_isAssistantOpen = true;
|
_isAssistantOpen = true;
|
||||||
@@ -411,31 +395,27 @@
|
|||||||
Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
|
Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await InitViewportDetectionAsync();
|
try
|
||||||
|
{
|
||||||
|
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
|
||||||
|
await InitViewportDetectionAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to import viewport utilities JS module.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitViewportDetectionAsync()
|
private async Task InitViewportDetectionAsync()
|
||||||
{
|
{
|
||||||
|
if (_viewportModule == null) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_selfReference = DotNetObjectReference.Create(this);
|
_selfReference = DotNetObjectReference.Create(this);
|
||||||
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
|
var isMobileViewport = await _viewportModule.InvokeAsync<bool>("isMobileViewport");
|
||||||
await OnViewportChanged(isMobileViewport);
|
await OnViewportChanged(isMobileViewport);
|
||||||
|
await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference);
|
||||||
await JS.InvokeVoidAsync("eval", @"
|
|
||||||
window.registerViewportObserver = (dotNetHelper) => {
|
|
||||||
let currentIsMobile = window.innerWidth < 768;
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
let isMobile = window.innerWidth < 768;
|
|
||||||
if (isMobile !== currentIsMobile) {
|
|
||||||
currentIsMobile = isMobile;
|
|
||||||
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
");
|
|
||||||
await JS.InvokeVoidAsync("registerViewportObserver", _selfReference);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -456,7 +436,7 @@
|
|||||||
|
|
||||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
public void Dispose()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||||
@@ -465,6 +445,25 @@
|
|||||||
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
||||||
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
|
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
|
||||||
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_viewportModule != null)
|
||||||
|
{
|
||||||
|
if (_selfReference != null)
|
||||||
|
{
|
||||||
|
await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
|
||||||
|
}
|
||||||
|
await _viewportModule.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogDebug(ex, "Teardown of viewport observer module failed during component disposal.");
|
||||||
|
}
|
||||||
|
|
||||||
_selfReference?.Dispose();
|
_selfReference?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
|
||||||
|
namespace NexusReader.UI.Shared.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the active tab state for the unified mobile reader toolbar.
|
||||||
|
/// </summary>
|
||||||
|
public enum MobileReaderTab
|
||||||
|
{
|
||||||
|
Reader,
|
||||||
|
Graph,
|
||||||
|
Concepts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Screen coordinates for text selection popup positioning.
|
||||||
|
/// </summary>
|
||||||
|
public record SelectionCoordinates(double Top, double Left, double Width);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a message in the KM-RAG global and mobile intelligence chat threads.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatMessage
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public string Sender { get; set; } = string.Empty; // "User" or "AI"
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
public List<ResponseSegment> Segments { get; set; } = new();
|
||||||
|
public List<CitationDto> Citations { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parsed segment of an intelligence response, potentially referencing a citation.
|
||||||
|
/// </summary>
|
||||||
|
public class ResponseSegment
|
||||||
|
{
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
public bool IsCitation { get; set; }
|
||||||
|
public string CitationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@using NexusReader.Application.Abstractions.Services
|
@using NexusReader.Application.Abstractions.Services
|
||||||
@using NexusReader.Application.DTOs.User
|
@using NexusReader.Application.DTOs.User
|
||||||
@using NexusReader.UI.Shared.Components.Atoms
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
|
@using NexusReader.UI.Shared.Models
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
@inject IKnowledgeService KnowledgeService
|
@inject IKnowledgeService KnowledgeService
|
||||||
@@ -145,22 +146,7 @@
|
|||||||
private List<LastReadBookDto>? _books;
|
private List<LastReadBookDto>? _books;
|
||||||
private List<ChatMessage> _chatMessages = new();
|
private List<ChatMessage> _chatMessages = new();
|
||||||
|
|
||||||
public class ChatMessage
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
|
||||||
public string Sender { get; set; } = string.Empty; // "User" or "AI"
|
|
||||||
public string Text { get; set; } = string.Empty;
|
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
|
||||||
public List<ResponseSegment> Segments { get; set; } = new();
|
|
||||||
public List<CitationDto> Citations { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ResponseSegment
|
|
||||||
{
|
|
||||||
public string Text { get; set; } = string.Empty;
|
|
||||||
public bool IsCitation { get; set; }
|
|
||||||
public string CitationId { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,21 +109,16 @@ else
|
|||||||
|
|
||||||
private void LogInfo()
|
private void LogInfo()
|
||||||
{
|
{
|
||||||
#if DEBUG
|
|
||||||
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo");
|
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo");
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LogWarning()
|
private void LogWarning()
|
||||||
{
|
{
|
||||||
#if DEBUG
|
|
||||||
Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow);
|
Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow);
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LogError()
|
private void LogError()
|
||||||
{
|
{
|
||||||
#if DEBUG
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard.");
|
throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard.");
|
||||||
@@ -132,22 +127,31 @@ else
|
|||||||
{
|
{
|
||||||
Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!");
|
Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!");
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TriggerJsLog()
|
private async Task TriggerJsLog()
|
||||||
{
|
{
|
||||||
#if DEBUG
|
try
|
||||||
await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!");
|
{
|
||||||
#endif
|
await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!");
|
||||||
await Task.CompletedTask;
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed to execute console.log from diagnostic panel.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TriggerJsException()
|
private async Task TriggerJsException()
|
||||||
{
|
{
|
||||||
#if DEBUG
|
try
|
||||||
await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');");
|
{
|
||||||
#endif
|
// Triggers a TypeError by invoking a non-existent method, which is completely CSP-compliant and works without eval()
|
||||||
await Task.CompletedTask;
|
await JSRuntime.InvokeVoidAsync("window.nonExistentFunctionTriggeringException");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Simulated runtime JS Exception triggered and captured in Blazor UI");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using NexusReader.UI.Shared.Models;
|
||||||
|
|
||||||
namespace NexusReader.UI.Shared.Services;
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
public interface IReaderInteractionService
|
public interface IReaderInteractionService
|
||||||
@@ -10,10 +12,6 @@ public interface IReaderInteractionService
|
|||||||
event Func<int, Task>? OnScrollPercentChanged;
|
event Func<int, Task>? OnScrollPercentChanged;
|
||||||
event Func<string, Task>? OnBlockReached;
|
event Func<string, Task>? OnBlockReached;
|
||||||
|
|
||||||
int CurrentScrollPercentage { get; set; }
|
|
||||||
List<string> CurrentCheckpoints { get; set; }
|
|
||||||
string CurrentBlockId { get; set; }
|
|
||||||
|
|
||||||
Task NotifyNodeSelected(string nodeId);
|
Task NotifyNodeSelected(string nodeId);
|
||||||
Task RequestScrollToBlock(string blockId);
|
Task RequestScrollToBlock(string blockId);
|
||||||
Task RequestHighlightBlock(string blockId);
|
Task RequestHighlightBlock(string blockId);
|
||||||
@@ -23,4 +21,3 @@ public interface IReaderInteractionService
|
|||||||
Task NotifyBlockReached(string blockId);
|
Task NotifyBlockReached(string blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SelectionCoordinates(double Top, double Left, double Width);
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using NexusReader.UI.Shared.Models;
|
||||||
|
|
||||||
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service to maintain local UI state for the reader, separating state from event bus.
|
||||||
|
/// </summary>
|
||||||
|
public interface IReaderStateService
|
||||||
|
{
|
||||||
|
int CurrentScrollPercentage { get; set; }
|
||||||
|
List<string> CurrentCheckpoints { get; set; }
|
||||||
|
string CurrentBlockId { get; set; }
|
||||||
|
MobileReaderTab ActiveTab { get; set; }
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using NexusReader.UI.Shared.Models;
|
||||||
|
|
||||||
namespace NexusReader.UI.Shared.Services;
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
public sealed class ReaderInteractionService : IReaderInteractionService
|
public sealed class ReaderInteractionService : IReaderInteractionService
|
||||||
@@ -10,10 +12,6 @@ public sealed class ReaderInteractionService : IReaderInteractionService
|
|||||||
public event Func<int, Task>? OnScrollPercentChanged;
|
public event Func<int, Task>? OnScrollPercentChanged;
|
||||||
public event Func<string, Task>? OnBlockReached;
|
public event Func<string, Task>? OnBlockReached;
|
||||||
|
|
||||||
public int CurrentScrollPercentage { get; set; }
|
|
||||||
public List<string> CurrentCheckpoints { get; set; } = new();
|
|
||||||
public string CurrentBlockId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public async Task NotifyNodeSelected(string nodeId)
|
public async Task NotifyNodeSelected(string nodeId)
|
||||||
{
|
{
|
||||||
if (OnNodeSelected != null) await OnNodeSelected(nodeId);
|
if (OnNodeSelected != null) await OnNodeSelected(nodeId);
|
||||||
@@ -41,13 +39,12 @@ public sealed class ReaderInteractionService : IReaderInteractionService
|
|||||||
|
|
||||||
public async Task NotifyScrollPercentChanged(int percent)
|
public async Task NotifyScrollPercentChanged(int percent)
|
||||||
{
|
{
|
||||||
CurrentScrollPercentage = percent;
|
|
||||||
if (OnScrollPercentChanged != null) await OnScrollPercentChanged(percent);
|
if (OnScrollPercentChanged != null) await OnScrollPercentChanged(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task NotifyBlockReached(string blockId)
|
public async Task NotifyBlockReached(string blockId)
|
||||||
{
|
{
|
||||||
CurrentBlockId = blockId;
|
|
||||||
if (OnBlockReached != null) await OnBlockReached(blockId);
|
if (OnBlockReached != null) await OnBlockReached(blockId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using NexusReader.UI.Shared.Models;
|
||||||
|
|
||||||
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe implementation of IReaderStateService.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReaderStateService : IReaderStateService
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private int _scrollPercent;
|
||||||
|
private List<string> _checkpoints = new();
|
||||||
|
private string _blockId = string.Empty;
|
||||||
|
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
|
||||||
|
|
||||||
|
public int CurrentScrollPercentage
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _scrollPercent; }
|
||||||
|
set { lock (_lock) _scrollPercent = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> CurrentCheckpoints
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _checkpoints; }
|
||||||
|
set { lock (_lock) _checkpoints = value ?? new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentBlockId
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _blockId; }
|
||||||
|
set { lock (_lock) _blockId = value ?? string.Empty; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public MobileReaderTab ActiveTab
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _activeTab; }
|
||||||
|
set { lock (_lock) _activeTab = value; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Viewport and scrolling utilities for NexusReader.
|
||||||
|
* Avoids eval() usage, supports CSP, AOT-safety, and prevents memory leaks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isMobileViewport() {
|
||||||
|
return window.innerWidth < 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerViewportObserver(dotNetHelper) {
|
||||||
|
let currentIsMobile = window.innerWidth < 768;
|
||||||
|
|
||||||
|
const listener = () => {
|
||||||
|
const isMobile = window.innerWidth < 768;
|
||||||
|
if (isMobile !== currentIsMobile) {
|
||||||
|
currentIsMobile = isMobile;
|
||||||
|
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store listener directly on the JS object wrapper of the DotNetObjectReference for elegant cleanup
|
||||||
|
dotNetHelper._viewportListener = listener;
|
||||||
|
window.addEventListener('resize', listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterViewportObserver(dotNetHelper) {
|
||||||
|
if (dotNetHelper && dotNetHelper._viewportListener) {
|
||||||
|
window.removeEventListener('resize', dotNetHelper._viewportListener);
|
||||||
|
delete dotNetHelper._viewportListener;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollIntoView(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
|||||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
|
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
|||||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
|
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user