feat(ui): implement premium mobile-first reader layout with three-tab bottom navigation and assistant FAB
This commit is contained in:
@@ -13,6 +13,8 @@
|
|||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
@inject ISyncService SyncService
|
@inject ISyncService SyncService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject IQuizStateService QuizService
|
||||||
|
@inject IPlatformService PlatformService
|
||||||
@inject ILogger<ReaderCanvas> Logger
|
@inject ILogger<ReaderCanvas> Logger
|
||||||
|
|
||||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||||
@@ -53,6 +55,17 @@
|
|||||||
BlockId="@_selectedBlockId"
|
BlockId="@_selectedBlockId"
|
||||||
Coordinates="@_selectionCoords"
|
Coordinates="@_selectionCoords"
|
||||||
FullPageContent="@GetFullPageContent()" />
|
FullPageContent="@GetFullPageContent()" />
|
||||||
|
|
||||||
|
@if (_isMobile)
|
||||||
|
{
|
||||||
|
<button class="nexus-mobile-assistant-fab @(QuizService.HasNewQuiz ? "has-new-quiz" : "")" @onclick="HandleAssistantFabClick" aria-label="Asystent AI">
|
||||||
|
<NexusIcon Name="robot" Size="24" Class="neon-glow" />
|
||||||
|
@if (QuizService.HasNewQuiz)
|
||||||
|
{
|
||||||
|
<span class="fab-badge"></span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -68,17 +81,29 @@
|
|||||||
private ElementReference _containerRef;
|
private ElementReference _containerRef;
|
||||||
private bool _isInteractive;
|
private bool _isInteractive;
|
||||||
private string? _currentActiveBlockId;
|
private string? _currentActiveBlockId;
|
||||||
|
private bool _isMobile = false;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await Coordinator.ClearAsync();
|
await Coordinator.ClearAsync();
|
||||||
ThemeService.OnThemeChanged += HandleUpdate;
|
ThemeService.OnThemeChanged += HandleUpdate;
|
||||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||||
|
QuizService.OnQuizUpdated += HandleUpdate;
|
||||||
|
|
||||||
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
||||||
InteractionService.OnTextSelected += HandleTextSelected;
|
InteractionService.OnTextSelected += HandleTextSelected;
|
||||||
SyncService.OnProgressReceived += HandleSyncProgressReceived;
|
SyncService.OnProgressReceived += HandleSyncProgressReceived;
|
||||||
|
|
||||||
|
var context = PlatformService.GetDeviceContext();
|
||||||
|
if (context.IsSuccess)
|
||||||
|
{
|
||||||
|
_isMobile = context.Value.DeviceType switch
|
||||||
|
{
|
||||||
|
DeviceType.Phone or DeviceType.Tablet => true,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
@@ -286,10 +311,16 @@
|
|||||||
|
|
||||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private async Task HandleAssistantFabClick()
|
||||||
|
{
|
||||||
|
await InteractionService.RequestAssistant();
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged -= HandleUpdate;
|
ThemeService.OnThemeChanged -= HandleUpdate;
|
||||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||||
|
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||||
|
|
||||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||||
|
|||||||
@@ -4,9 +4,31 @@
|
|||||||
@using NexusReader.Application.Abstractions.Services
|
@using NexusReader.Application.Abstractions.Services
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
|
||||||
<div class="hub-container">
|
<div class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
|
<!-- Mobile Sticky Top-bar -->
|
||||||
|
<div class="nexus-mobile-topbar">
|
||||||
|
<button class="hamburger-btn" @onclick="ToggleMobileMenu" aria-label="Toggle Menu">
|
||||||
|
<NexusIcon Name="menu" Size="24" />
|
||||||
|
</button>
|
||||||
|
<div class="mobile-logo">
|
||||||
|
<NexusIcon Name="diamond" Size="20" Class="logo-icon pulsing-logo" />
|
||||||
|
<span class="logo-text">Nexus</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-user-pill">
|
||||||
|
<div class="user-avatar-mini">
|
||||||
|
@context.User.Identity?.Name?[0].ToString().ToUpper()
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Backdrop overlay -->
|
||||||
|
@if (_isMobileMenuOpen)
|
||||||
|
{
|
||||||
|
<div class="mobile-sidebar-backdrop" @onclick="CloseMobileMenu"></div>
|
||||||
|
}
|
||||||
|
|
||||||
<aside class="hub-sidebar">
|
<aside class="hub-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
@@ -16,43 +38,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All">
|
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All" @onclick="CloseMobileMenu">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<NexusIcon Name="home" Size="18" />
|
<NexusIcon Name="home" Size="18" />
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Dashboard</span>
|
<span class="nav-text">Dashboard</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="/library">
|
<NavLink class="nav-item" href="/library" @onclick="CloseMobileMenu">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<NexusIcon Name="book-open" Size="18" />
|
<NexusIcon Name="book-open" Size="18" />
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Library</span>
|
<span class="nav-text">Library</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="/concepts-map">
|
<NavLink class="nav-item" href="/concepts-map" @onclick="CloseMobileMenu">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<NexusIcon Name="map" Size="18" />
|
<NexusIcon Name="map" Size="18" />
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Concepts Map</span>
|
<span class="nav-text">Concepts Map</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="/intelligence">
|
<NavLink class="nav-item" href="/intelligence" @onclick="CloseMobileMenu">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<NexusIcon Name="cpu" Size="18" />
|
<NexusIcon Name="cpu" Size="18" />
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Global AI Q&A</span>
|
<span class="nav-text">Global AI Q&A</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="/profile">
|
<NavLink class="nav-item" href="/profile" @onclick="CloseMobileMenu">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<NexusIcon Name="message-square" Size="18" />
|
<NexusIcon Name="message-square" Size="18" />
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Profile</span>
|
<span class="nav-text">Profile</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="/settings">
|
<NavLink class="nav-item" href="/settings" @onclick="CloseMobileMenu">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<NexusIcon Name="settings" Size="18" />
|
<NexusIcon Name="settings" Size="18" />
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Settings</span>
|
<span class="nav-text">Settings</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="/concenters">
|
<NavLink class="nav-item" href="/concenters" @onclick="CloseMobileMenu">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<NexusIcon Name="target" Size="18" />
|
<NexusIcon Name="target" Size="18" />
|
||||||
</div>
|
</div>
|
||||||
@@ -90,6 +112,7 @@
|
|||||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||||
|
|
||||||
private bool _isSyncing = false;
|
private bool _isSyncing = false;
|
||||||
|
private bool _isMobileMenuOpen = false;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -104,8 +127,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ToggleMobileMenu()
|
||||||
|
{
|
||||||
|
_isMobileMenuOpen = !_isMobileMenuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseMobileMenu()
|
||||||
|
{
|
||||||
|
_isMobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleLogout()
|
private async Task HandleLogout()
|
||||||
{
|
{
|
||||||
|
CloseMobileMenu();
|
||||||
await IdentityService.LogoutAsync();
|
await IdentityService.LogoutAsync();
|
||||||
NavigationManager.NavigateTo("/account/logout-form", true);
|
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,4 +190,157 @@
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Styles */
|
||||||
|
.nexus-mobile-topbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nexus-mobile-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(18, 18, 18, 0.85);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
z-index: 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulsing-logo {
|
||||||
|
animation: pulse-glow 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% {
|
||||||
|
filter: drop-shadow(0 0 5px rgba(0, 255, 153, 0.4));
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 12px rgba(0, 255, 153, 0.8));
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-mini {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(135deg, var(--nexus-neon) 0%, #0099ff 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #121212;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 153, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
z-index: 190;
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .hub-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 280px;
|
||||||
|
height: 100%;
|
||||||
|
background: #141414;
|
||||||
|
z-index: 200;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
will-change: transform;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-open ::deep .hub-sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-main {
|
||||||
|
margin-top: 60px;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .sidebar-header {
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .sidebar-nav {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .nav-item {
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 48px; /* Touch target */
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .sidebar-footer {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
|
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
|
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}")">
|
||||||
<div class="reader-pane">
|
<div class="reader-pane">
|
||||||
<main>
|
<main>
|
||||||
@Body
|
@Body
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
|
@if (!_isMobile)
|
||||||
|
{
|
||||||
<div class="resizer" id="sidebar-resizer"></div>
|
<div class="resizer" id="sidebar-resizer"></div>
|
||||||
|
|
||||||
<div class="intelligence-sidebar">
|
<div class="intelligence-sidebar">
|
||||||
@@ -47,12 +49,9 @@
|
|||||||
@if (_activeTab == SidebarTab.Knowledge)
|
@if (_activeTab == SidebarTab.Knowledge)
|
||||||
{
|
{
|
||||||
<div class="intelligence-scroll-area stacked-layout">
|
<div class="intelligence-scroll-area stacked-layout">
|
||||||
@if (!_isMobile)
|
|
||||||
{
|
|
||||||
<div class="visual-workspace">
|
<div class="visual-workspace">
|
||||||
<KnowledgeGraph />
|
<KnowledgeGraph />
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
<div class="contextual-intelligence-panel">
|
<div class="contextual-intelligence-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -132,6 +131,120 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<!-- Mobile full-bleed containers mapped to bottom tab navigation -->
|
||||||
|
<div class="nexus-mobile-reader-tabs">
|
||||||
|
<!-- Tab 2: Graph -->
|
||||||
|
<div class="nexus-mobile-tab-content graph-tab @(_activeMobileTab == MobileReaderTab.Graph ? "active" : "")">
|
||||||
|
<KnowledgeGraph />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 3: Insight (Contextual Intelligence AND Knowledge Quiz) -->
|
||||||
|
<div class="nexus-mobile-tab-content insight-tab @(_activeMobileTab == MobileReaderTab.Insight ? "active" : "")">
|
||||||
|
<div class="mobile-insight-container">
|
||||||
|
<div class="mobile-insight-header">
|
||||||
|
<div class="mobile-insight-nav">
|
||||||
|
<button class="mobile-insight-nav-btn @(_activeTab == SidebarTab.Knowledge ? "active" : "")" @onclick="() => SetActiveTab(SidebarTab.Knowledge)">
|
||||||
|
<NexusIcon Name="brain" Size="16" />
|
||||||
|
<span>Podgląd pojęcia</span>
|
||||||
|
</button>
|
||||||
|
<button class="mobile-insight-nav-btn quiz-btn @(_activeTab == SidebarTab.Quiz ? "active" : "") @(QuizService.HasNewQuiz ? "quiz-pulse" : "")" @onclick="() => SetActiveTab(SidebarTab.Quiz)">
|
||||||
|
<NexusIcon Name="quiz" Size="16" />
|
||||||
|
<span>Quiz wiedzy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-insight-body">
|
||||||
|
@if (_activeTab == SidebarTab.Knowledge)
|
||||||
|
{
|
||||||
|
<div class="contextual-intelligence-panel">
|
||||||
|
<div class="panel-body">
|
||||||
|
@if (_selectedNode != null)
|
||||||
|
{
|
||||||
|
<div class="node-details">
|
||||||
|
<div class="node-header-section">
|
||||||
|
<span class="node-group-badge @(_selectedNode.Group.ToLower())">@(_selectedNode.Group.ToUpper())</span>
|
||||||
|
<h3 class="node-label">@_selectedNode.Label</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_selectedNode.Description))
|
||||||
|
{
|
||||||
|
<div class="detail-section">
|
||||||
|
<p class="node-description">@_selectedNode.Description</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_selectedNode.Summary))
|
||||||
|
{
|
||||||
|
<div class="detail-section summary-section">
|
||||||
|
<h4 class="section-title neon-sub-header">Podsumowanie</h4>
|
||||||
|
<p class="node-summary">@_selectedNode.Summary</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_selectedNode.KeyTerms != null && _selectedNode.KeyTerms.Any())
|
||||||
|
{
|
||||||
|
<div class="detail-section key-terms-section">
|
||||||
|
<h4 class="section-title neon-sub-header">Kluczowe Pojęcia</h4>
|
||||||
|
<ul class="key-terms-list">
|
||||||
|
@foreach (var term in _selectedNode.KeyTerms)
|
||||||
|
{
|
||||||
|
<li class="key-term-item">
|
||||||
|
<span class="term-bullet">•</span>
|
||||||
|
<span class="term-text">@term</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="no-node-selected">
|
||||||
|
<div class="placeholder-glow"></div>
|
||||||
|
<p class="placeholder-text">Wybierz pojęcie na wykresie, aby wyświetlić jego podsumowanie.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mobile-quiz-wrapper">
|
||||||
|
<KnowledgeCheck />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Three-Tab Fixed Bottom Navigation Bar -->
|
||||||
|
<div class="nexus-mobile-bottom-nav">
|
||||||
|
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Reader ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Reader)">
|
||||||
|
<NexusIcon Name="book-open" Size="20" />
|
||||||
|
<span>Czytnik</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Graph ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Graph)">
|
||||||
|
<NexusIcon Name="network" Size="20" />
|
||||||
|
<span>Wykres</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Insight ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Insight)">
|
||||||
|
<span class="insight-icon-wrapper">
|
||||||
|
<NexusIcon Name="brain" Size="20" />
|
||||||
|
@if (QuizService.HasNewQuiz)
|
||||||
|
{
|
||||||
|
<span class="nav-quiz-indicator"></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>Analiza</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<Authorizing>
|
<Authorizing>
|
||||||
<div class="app-preloader">
|
<div class="app-preloader">
|
||||||
@@ -155,7 +268,15 @@
|
|||||||
Quiz
|
Quiz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum MobileReaderTab
|
||||||
|
{
|
||||||
|
Reader,
|
||||||
|
Graph,
|
||||||
|
Insight
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -169,6 +290,7 @@
|
|||||||
QuizService.OnQuizRequested += HandleQuizRequestedAsync;
|
QuizService.OnQuizRequested += HandleQuizRequestedAsync;
|
||||||
|
|
||||||
InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
|
InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
|
||||||
|
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
|
||||||
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
||||||
|
|
||||||
var context = PlatformService.GetDeviceContext();
|
var context = PlatformService.GetDeviceContext();
|
||||||
@@ -190,8 +312,25 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetMobileTab(MobileReaderTab tab)
|
||||||
|
{
|
||||||
|
_activeMobileTab = tab;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleQuizRequestedAsync(string blockId)
|
private async Task HandleQuizRequestedAsync(string blockId)
|
||||||
{
|
{
|
||||||
|
_activeTab = SidebarTab.Quiz;
|
||||||
|
if (_isMobile)
|
||||||
|
{
|
||||||
|
_activeMobileTab = MobileReaderTab.Insight;
|
||||||
|
}
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleAssistantRequestedAsync()
|
||||||
|
{
|
||||||
|
_activeMobileTab = MobileReaderTab.Insight;
|
||||||
_activeTab = SidebarTab.Quiz;
|
_activeTab = SidebarTab.Quiz;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
@@ -203,6 +342,11 @@
|
|||||||
{
|
{
|
||||||
_selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId);
|
_selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId);
|
||||||
}
|
}
|
||||||
|
if (_isMobile)
|
||||||
|
{
|
||||||
|
_activeMobileTab = MobileReaderTab.Insight;
|
||||||
|
_activeTab = SidebarTab.Knowledge;
|
||||||
|
}
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +381,7 @@
|
|||||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||||
QuizService.OnQuizRequested -= HandleQuizRequestedAsync;
|
QuizService.OnQuizRequested -= HandleQuizRequestedAsync;
|
||||||
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
|
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
|
||||||
|
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
||||||
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,3 +468,285 @@ main {
|
|||||||
color: var(--nexus-neon, #00f0ff);
|
color: var(--nexus-neon, #00f0ff);
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile-First Platform Customization */
|
||||||
|
.platform-mobile {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-mobile .reader-pane {
|
||||||
|
width: 100vw !important;
|
||||||
|
height: calc(100vh - 60px) !important; /* reserve bottom nav height */
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three-tab mobile views depending on the active mobile tab class */
|
||||||
|
.app-container.platform-mobile.active-mobile-tab-reader .reader-pane {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs .graph-tab {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container.platform-mobile.active-mobile-tab-insight .nexus-mobile-reader-tabs .insight-tab {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile full-bleed tabs container */
|
||||||
|
.nexus-mobile-reader-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-mobile .nexus-mobile-reader-tabs {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: #0d0d0d;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-mobile-tab-content {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active tab display with smooth slide-up / fade-in transition */
|
||||||
|
.nexus-mobile-tab-content.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: tab-transition 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tab-transition {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inside Mobile Graph Tab: full bleed and responsive */
|
||||||
|
.nexus-mobile-tab-content.graph-tab {
|
||||||
|
background: #09090b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-mobile-tab-content.graph-tab :deep(svg) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Insight container & tabs */
|
||||||
|
.mobile-insight-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-header {
|
||||||
|
background: rgba(13, 13, 13, 0.95);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-nav {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-nav-btn.active {
|
||||||
|
background: rgba(0, 240, 255, 0.1);
|
||||||
|
color: var(--nexus-neon, #00f0ff);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 240, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-nav-btn.quiz-btn.quiz-pulse {
|
||||||
|
animation: quiz-pulse-btn-anim 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #09090b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-body .contextual-intelligence-panel {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-insight-body .contextual-intelligence-panel .panel-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-quiz-wrapper {
|
||||||
|
padding: 1.25rem;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three-Tab Bottom Navigation Bar styling */
|
||||||
|
.nexus-mobile-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-mobile .nexus-mobile-bottom-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(13, 13, 13, 0.95);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
height: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item.active {
|
||||||
|
color: var(--nexus-neon, #00f0ff);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item.active :deep(svg) {
|
||||||
|
filter: drop-shadow(0 0 5px var(--nexus-neon, #00f0ff));
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-icon-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-quiz-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #f43f5e;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 8px #f43f5e;
|
||||||
|
animation: indicator-flash 1.5s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes indicator-flash {
|
||||||
|
0% { transform: scale(0.8); opacity: 0.6; }
|
||||||
|
50% { transform: scale(1.2); opacity: 1; }
|
||||||
|
100% { transform: scale(0.8); opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assistant FAB styling inside ReaderCanvas */
|
||||||
|
:global(.nexus-mobile-assistant-fab) {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 75px;
|
||||||
|
right: 20px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 240, 255, 0.15) 0%, rgba(0, 100, 255, 0.15) 100%);
|
||||||
|
border: 1px solid rgba(0, 240, 255, 0.4);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 99;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.nexus-mobile-assistant-fab:hover) {
|
||||||
|
transform: scale(1.1) translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 240, 255, 0.4);
|
||||||
|
border-color: var(--nexus-neon, #00f0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.nexus-mobile-assistant-fab:active) {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.nexus-mobile-assistant-fab.has-new-quiz) {
|
||||||
|
border-color: #f43f5e;
|
||||||
|
box-shadow: 0 4px 20px rgba(244, 63, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.nexus-mobile-assistant-fab .fab-badge) {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #f43f5e;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 10px #f43f5e;
|
||||||
|
animation: indicator-flash 1.5s infinite ease-in-out;
|
||||||
|
}
|
||||||
@@ -528,3 +528,81 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Dashboard Overrides */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-content {
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-visual {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrapper {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pills {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
gap: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
gap: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force all widgets to take 100% width and fit inside parent container nicely */
|
||||||
|
.glass-panel {
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 1.25rem !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand touch-targets to 48px min height for interactive elements */
|
||||||
|
.btn-nexus, .quiz-option, .satellite, .logout-btn, .nav-item, .quiz-item {
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ public interface IReaderInteractionService
|
|||||||
event Func<string, Task>? OnScrollToBlockRequested;
|
event Func<string, Task>? OnScrollToBlockRequested;
|
||||||
event Func<string, Task>? OnHighlightBlockRequested;
|
event Func<string, Task>? OnHighlightBlockRequested;
|
||||||
event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
||||||
|
event Func<Task>? OnAssistantRequested;
|
||||||
|
|
||||||
Task NotifyNodeSelected(string nodeId);
|
Task NotifyNodeSelected(string nodeId);
|
||||||
Task RequestScrollToBlock(string blockId);
|
Task RequestScrollToBlock(string blockId);
|
||||||
Task RequestHighlightBlock(string blockId);
|
Task RequestHighlightBlock(string blockId);
|
||||||
Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
|
Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
|
||||||
|
Task RequestAssistant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SelectionCoordinates(double Top, double Left, double Width);
|
public record SelectionCoordinates(double Top, double Left, double Width);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public sealed class ReaderInteractionService : IReaderInteractionService
|
|||||||
public event Func<string, Task>? OnScrollToBlockRequested;
|
public event Func<string, Task>? OnScrollToBlockRequested;
|
||||||
public event Func<string, Task>? OnHighlightBlockRequested;
|
public event Func<string, Task>? OnHighlightBlockRequested;
|
||||||
public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
||||||
|
public event Func<Task>? OnAssistantRequested;
|
||||||
|
|
||||||
public async Task NotifyNodeSelected(string nodeId)
|
public async Task NotifyNodeSelected(string nodeId)
|
||||||
{
|
{
|
||||||
@@ -26,4 +27,9 @@ public sealed class ReaderInteractionService : IReaderInteractionService
|
|||||||
{
|
{
|
||||||
if (OnTextSelected != null) await OnTextSelected(text, blockId, coords);
|
if (OnTextSelected != null) await OnTextSelected(text, blockId, coords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RequestAssistant()
|
||||||
|
{
|
||||||
|
if (OnAssistantRequested != null) await OnAssistantRequested();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,85 @@ let svgElement;
|
|||||||
|
|
||||||
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
|
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
|
||||||
|
|
||||||
|
let isMobileMode = false;
|
||||||
|
let activeNodeId = null;
|
||||||
|
|
||||||
|
const getNodeGlyph = d => {
|
||||||
|
if (!d) return 'C';
|
||||||
|
const type = getNodeType(d);
|
||||||
|
const group = getNodeGroup(d);
|
||||||
|
if (type === 'rule') return '§';
|
||||||
|
if (type === 'definition') return 'D';
|
||||||
|
if (type === 'table') return 'T';
|
||||||
|
if (type === 'section') return 'S';
|
||||||
|
if (group === 'bridge') return 'B';
|
||||||
|
if (group === 'current') return '★';
|
||||||
|
return 'C';
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateNodeAppearances() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
node.each(function(d) {
|
||||||
|
const g = d3.select(this);
|
||||||
|
const rect = g.select(".node-pill");
|
||||||
|
const text = g.select("text");
|
||||||
|
|
||||||
|
const isCurrent = getNodeGroup(d) === 'current';
|
||||||
|
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||||
|
const showFull = !isMobileMode || isSelected || isCurrent;
|
||||||
|
|
||||||
|
if (showFull) {
|
||||||
|
rect.transition().duration(250)
|
||||||
|
.attr("x", -getPillWidth(d) / 2)
|
||||||
|
.attr("width", getPillWidth(d))
|
||||||
|
.attr("height", 30)
|
||||||
|
.attr("rx", 15)
|
||||||
|
.attr("y", -15);
|
||||||
|
|
||||||
|
text.text(getDisplayLabel(d))
|
||||||
|
.attr("font-size", isCurrent || isSelected ? "0.85rem" : "0.8rem")
|
||||||
|
.attr("font-weight", isCurrent || isSelected ? "600" : "normal");
|
||||||
|
} else {
|
||||||
|
rect.transition().duration(250)
|
||||||
|
.attr("x", -15)
|
||||||
|
.attr("width", 30)
|
||||||
|
.attr("height", 30)
|
||||||
|
.attr("rx", 15)
|
||||||
|
.attr("y", -15);
|
||||||
|
|
||||||
|
text.text(getNodeGlyph(d))
|
||||||
|
.attr("font-size", "0.9rem")
|
||||||
|
.attr("font-weight", "bold");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMobileMode(isMobile) {
|
||||||
|
isMobileMode = isMobile;
|
||||||
|
if (!simulation) return;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
simulation.force("charge", d3.forceManyBody().strength(-60));
|
||||||
|
simulation.force("link").distance(180);
|
||||||
|
simulation.force("collide", d3.forceCollide().radius(d => {
|
||||||
|
const isCurrent = getNodeGroup(d) === 'current';
|
||||||
|
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||||
|
if (isCurrent || isSelected) {
|
||||||
|
return (getPillWidth(d) / 2) + 15;
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
simulation.force("charge", d3.forceManyBody().strength(-400));
|
||||||
|
simulation.force("link").distance(120);
|
||||||
|
simulation.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNodeAppearances();
|
||||||
|
simulation.alpha(0.3).restart();
|
||||||
|
}
|
||||||
|
|
||||||
export function mount(containerId, data, dotNetHelper) {
|
export function mount(containerId, data, dotNetHelper) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -204,11 +283,21 @@ export function mount(containerId, data, dotNetHelper) {
|
|||||||
});
|
});
|
||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
|
isMobileMode = window.innerWidth < 768;
|
||||||
|
|
||||||
simulation = d3.forceSimulation()
|
simulation = d3.forceSimulation()
|
||||||
.force("link", d3.forceLink().id(d => d.id).distance(120))
|
.force("link", d3.forceLink().id(d => d.id).distance(isMobileMode ? 180 : 120))
|
||||||
.force("charge", d3.forceManyBody().strength(-400))
|
.force("charge", d3.forceManyBody().strength(isMobileMode ? -60 : -400))
|
||||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||||
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
|
.force("collide", d3.forceCollide().radius(d => {
|
||||||
|
if (isMobileMode) {
|
||||||
|
const isCurrent = getNodeGroup(d) === 'current';
|
||||||
|
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||||
|
if (isCurrent || isSelected) return (getPillWidth(d) / 2) + 15;
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
return (getPillWidth(d) / 2) + 20;
|
||||||
|
}));
|
||||||
|
|
||||||
simulation.on("tick", () => {
|
simulation.on("tick", () => {
|
||||||
if (link) {
|
if (link) {
|
||||||
@@ -317,22 +406,14 @@ export function updateData(data) {
|
|||||||
|
|
||||||
g.append("rect")
|
g.append("rect")
|
||||||
.attr("class", "node-pill")
|
.attr("class", "node-pill")
|
||||||
.attr("x", d => -getPillWidth(d) / 2)
|
|
||||||
.attr("y", -15)
|
|
||||||
.attr("width", d => getPillWidth(d))
|
|
||||||
.attr("height", 30)
|
|
||||||
.attr("rx", 15)
|
|
||||||
.attr("fill", "rgba(20, 20, 20, 0.95)")
|
.attr("fill", "rgba(20, 20, 20, 0.95)")
|
||||||
.attr("stroke", d => getCategoryStyle(d).color)
|
.attr("stroke", d => getCategoryStyle(d).color)
|
||||||
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
|
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
|
||||||
|
|
||||||
g.append("text")
|
g.append("text")
|
||||||
.text(d => getDisplayLabel(d))
|
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.attr("y", 5)
|
.attr("y", 5)
|
||||||
.attr("fill", d => getCategoryStyle(d).textColor)
|
.attr("fill", d => getCategoryStyle(d).textColor);
|
||||||
.attr("font-size", "0.8rem")
|
|
||||||
.attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
|
|
||||||
|
|
||||||
g.append("title")
|
g.append("title")
|
||||||
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
|
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
|
||||||
@@ -345,6 +426,8 @@ export function updateData(data) {
|
|||||||
exit => exit.transition().duration(500).style("opacity", 0).remove()
|
exit => exit.transition().duration(500).style("opacity", 0).remove()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
updateNodeAppearances();
|
||||||
|
|
||||||
simulation.nodes(data.nodes);
|
simulation.nodes(data.nodes);
|
||||||
simulation.force("link").links(validLinks);
|
simulation.force("link").links(validLinks);
|
||||||
simulation.alpha(0.5).restart();
|
simulation.alpha(0.5).restart();
|
||||||
@@ -377,6 +460,7 @@ function drag(simulation) {
|
|||||||
export function setActiveNode(nodeId) {
|
export function setActiveNode(nodeId) {
|
||||||
if (!svgElement || !node) return;
|
if (!svgElement || !node) return;
|
||||||
|
|
||||||
|
activeNodeId = nodeId;
|
||||||
// Safety check: ensure we only target the first occurrence if IDs are duplicated
|
// Safety check: ensure we only target the first occurrence if IDs are duplicated
|
||||||
const targetNode = node.filter(d => d.id === nodeId);
|
const targetNode = node.filter(d => d.id === nodeId);
|
||||||
if (targetNode.empty()) {
|
if (targetNode.empty()) {
|
||||||
@@ -399,6 +483,20 @@ export function setActiveNode(nodeId) {
|
|||||||
// Dim others (only exact matches for nodeId will be fully opaque)
|
// Dim others (only exact matches for nodeId will be fully opaque)
|
||||||
dimNodes(nodeId);
|
dimNodes(nodeId);
|
||||||
|
|
||||||
|
// Dynamic collision update if in mobile mode to expand active node
|
||||||
|
if (isMobileMode && simulation) {
|
||||||
|
simulation.force("collide", d3.forceCollide().radius(d => {
|
||||||
|
const isCurrent = getNodeGroup(d) === 'current';
|
||||||
|
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||||
|
if (isCurrent || isSelected) {
|
||||||
|
return (getPillWidth(d) / 2) + 15;
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNodeAppearances();
|
||||||
|
|
||||||
// Smooth transition to the first matching node
|
// Smooth transition to the first matching node
|
||||||
svgElement.transition().duration(1000).call(
|
svgElement.transition().duration(1000).call(
|
||||||
zoomBehavior.transform,
|
zoomBehavior.transform,
|
||||||
@@ -446,8 +544,15 @@ export function handleResize(containerId) {
|
|||||||
|
|
||||||
svgElement.attr("viewBox", [0, 0, width, height]);
|
svgElement.attr("viewBox", [0, 0, width, height]);
|
||||||
simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
||||||
|
|
||||||
|
const prevMobileMode = isMobileMode;
|
||||||
|
isMobileMode = window.innerWidth < 768;
|
||||||
|
if (isMobileMode !== prevMobileMode) {
|
||||||
|
setMobileMode(isMobileMode);
|
||||||
|
} else {
|
||||||
simulation.alpha(0.3).restart();
|
simulation.alpha(0.3).restart();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function scrollToNode(id) {
|
export function scrollToNode(id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user