feat(ui): implement premium gamified Concepts Map dashboard, unify design tokens, and enforce scoped CSS (#54)
Resolves #55 # gamified Concepts Map Dashboard, Architecture Cleanup, & CSS Unification This Pull Request completes the gamified **Concepts Map** interactive experience and executes a strict visual and architectural clean-up of the **NexusReader SaaS** platform by consolidating styling around the centralized **Nexus Neon** design system, enforcing Native AOT-compliant scoped-CSS, and resolving style drift. --- ### 🚀 Key Implementations #### 1. Gamified Interactive Concepts Map Dashboard * **Dynamic Skill-Tree Visualizer:** Implemented a gamified node-based interactive tree that reads concept connections dynamically, rendering progress nodes, unlocked/locked states, and active visual connection lines. * **Premium UX Details:** Integrated high-fidelity hover effects, detailed sidebar popovers showing unlocked stats/summaries, and smooth parallax backdrops. * **Secure Multi-Tenant Gating:** Hardened data queries using explicit `TenantId` gating to ensure complete layout isolation between system tenants. #### 2. Architecture & Service Optimization * **Service Abstraction (`IConceptsMapService`):** Introduced a clean service interface decoupled from the UI layers. * **Polyglot Fallback Implementations:** - **WasmConceptsMapService:** Implements efficient client-side fetching with error recovery/loading states. - **ServerConceptsMapService:** Handles deep database graph mapping, relationship resolution, and multi-tenant checks. * **CQRS Integrity:** Enforced the CQRS Result Pattern by using `MediatR` handlers to fetch data structures instead of placing database queries in UI modules. #### 3. Nexus Neon CSS Unification & Zero-Style-Tag Standards * **Central Design Tokens:** Solidified typography (`Inter` / `Merriweather`), primary brand green hues (`var(--nexus-neon)` at `#00ff99`), and glow variables inside the global `app.css`. * **Zero Inline Style Tags:** Standardized all dashboard modules by completely eliminating inline `<style>` blocks in favor of scoped `.razor.css` companion files. * **Consolidated Buttons & Glass Panels:** - Standardized `.btn-nexus`, `.btn-nexus-primary`, and `.btn-nexus-secondary` throughout header/footer/card operations. - Removed duplicate `.glass-panel` background, blur, and support-declaration overrides, making components cleanly inherit standard global styles. * **Eliminated Brand Splitting:** Resolved legacy purple-indigo user avatar and conversation bubble gradients inside the AI Intelligence Hub (`Intelligence.razor.css`), migrating them to premium glassmorphic surfaces (`rgba(255, 255, 255, 0.05)`) contrasting beautifully against the emerald green AI theme. --- ### 🧪 Verification & Build Gate Status * **Successful Compilation Check:** Run `dotnet build NexusReader.slnx --no-restore` from the workspace root. Flawlessly succeeded with **zero compilation errors** (`Liczba błędów: 0`) under target `.NET 10`. * **Verified Skills Synchrony:** The companion design guidelines skill (`nexus-ui-engine/SKILL.md`) has been fully updated to secure these layout and styling conventions for future dashboard features. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #54 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #54.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
@page "/book/{BookId:guid}/concepts"
|
||||
@page "/concepts-map"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.Queries.Graph
|
||||
@using NexusReader.Application.Queries.Concepts
|
||||
@using System.Net.Http.Json
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.Utilities
|
||||
@inject IConceptsMapService ConceptsMapService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IIdentityService IdentityService
|
||||
@inject ISyncService SyncService
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<PageTitle>Mapa Pojęć | Nexus Reader</PageTitle>
|
||||
|
||||
<div class="concepts-dashboard-container">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="loading-state glass-panel">
|
||||
<div class="preloader-robot">
|
||||
<NexusIcon Name="robot" Size="64" Class="neon-pulse" />
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<p class="loading-text">Inicjalizowanie mapy pojęć...</p>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="error-state glass-panel">
|
||||
<NexusIcon Name="alert-triangle" Size="48" Class="error-icon" />
|
||||
<h3>Wystąpił Błąd</h3>
|
||||
<p>@_errorMessage</p>
|
||||
<button class="btn-nexus btn-nexus-secondary" @onclick="LoadDataAsync">Spróbuj ponownie</button>
|
||||
</div>
|
||||
}
|
||||
else if (!BookId.HasValue || BookId.Value == Guid.Empty || Nodes == null || !Nodes.Any())
|
||||
{
|
||||
<div class="empty-dashboard-state glass-panel">
|
||||
<NexusIcon Name="book-open" Size="64" Class="dim-icon" />
|
||||
<h2>Brak Aktywnych Książek</h2>
|
||||
<p>Nie wybrano żadnej książki lub ta książka nie ma jeszcze wygenerowanej mapy pojęć przez Nexus AI.</p>
|
||||
<a href="/library" class="btn-nexus btn-nexus-primary">Przejdź do Biblioteki</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<header class="dashboard-header">
|
||||
<div class="header-back">
|
||||
<button class="btn-nexus btn-nexus-secondary btn-back" @onclick="GoBackToLibrary">
|
||||
<NexusIcon Name="arrow-left" Size="16" />
|
||||
<span>Biblioteka</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-title">
|
||||
<h1>Mapa Pojęć</h1>
|
||||
<span class="subtitle">Interaktywna ścieżka rozwoju Twoich postępów nauki</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-nexus btn-nexus-primary btn-action" @onclick="GoToReader">
|
||||
<span>Otwórz Czytnik</span>
|
||||
<NexusIcon Name="book-open" Size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="concepts-dashboard">
|
||||
<!-- Left Pane: Interactive Skill Tree -->
|
||||
<section class="left-pane glass-panel">
|
||||
<div class="pane-header">
|
||||
<h3><NexusIcon Name="map" Size="18" /> Ścieżka Rozwoju Wiedzy</h3>
|
||||
</div>
|
||||
<div class="pane-content">
|
||||
<ConceptsMap Nodes="Nodes"
|
||||
LastReadBlockId="LastReadBlockId"
|
||||
SelectedNode="SelectedNode"
|
||||
OnNodeSelected="HandleNodeSelected" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right Pane: Reactive Deep-Dive Workspace -->
|
||||
<section class="right-pane glass-panel">
|
||||
@if (SelectedNode == null)
|
||||
{
|
||||
<div class="workspace-empty">
|
||||
<div class="empty-glowing-brain">
|
||||
<NexusIcon Name="cpu" Size="48" Class="neon-pulse" />
|
||||
</div>
|
||||
<h4>Wybierz węzeł na mapie</h4>
|
||||
<p>Kliknij dowolne pojęcie z lewego panelu, aby uruchomić głęboką analizę i prześledzić szczegóły wygenerowane przez sztuczną inteligencję.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var isSelectedNodeUnlocked = IsUnlocked(SelectedNode.Id);
|
||||
|
||||
<div class="workspace-content">
|
||||
<div class="workspace-header">
|
||||
<div class="node-meta">
|
||||
<span class="node-id">@SelectedNode.Id.ToUpper()</span>
|
||||
@if (isSelectedNodeUnlocked)
|
||||
{
|
||||
<span class="badge badge-unlocked">
|
||||
<NexusIcon Name="check" Size="12" /> Odblokowane
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-locked">
|
||||
<NexusIcon Name="lock" Size="12" /> Zablokowane
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<h2 class="workspace-title">@SelectedNode.Label</h2>
|
||||
</div>
|
||||
|
||||
<div class="workspace-body scrollable-content">
|
||||
@if (!isSelectedNodeUnlocked)
|
||||
{
|
||||
<div class="locked-warning">
|
||||
<NexusIcon Name="lock" Size="20" Class="lock-warning-icon" />
|
||||
<div>
|
||||
<strong>Ten etap jest zablokowany</strong>
|
||||
<p>Kontynuuj czytanie książki, aby odblokować to pojęcie. Po przeczytaniu rozdziału, postęp zsynchronizuje się automatycznie.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="metadata-section">
|
||||
<h4><NexusIcon Name="info" Size="14" /> Opis Pojęcia</h4>
|
||||
<p class="section-text">@SelectedNode.Description</p>
|
||||
</div>
|
||||
|
||||
<div class="metadata-section">
|
||||
<h4><NexusIcon Name="file-text" Size="14" /> Podsumowanie AI</h4>
|
||||
<div class="summary-box">
|
||||
<p class="section-text">@SelectedNode.Summary</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (SelectedNode.KeyTerms != null && SelectedNode.KeyTerms.Any())
|
||||
{
|
||||
<div class="metadata-section">
|
||||
<h4><NexusIcon Name="list" Size="14" /> Kluczowe Terminy</h4>
|
||||
<div class="key-terms-grid">
|
||||
@foreach (var term in SelectedNode.KeyTerms)
|
||||
{
|
||||
<span class="term-pill">@term</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="workspace-footer">
|
||||
<button class="btn-nexus btn-nexus-primary w-100" @onclick="GoToSelectedChapter">
|
||||
<NexusIcon Name="book-open" Size="16" />
|
||||
<span>Czytaj ten rozdział</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid? BookId { get; set; }
|
||||
|
||||
private List<GraphNodeDto> Nodes { get; set; } = new();
|
||||
private string LastReadBlockId { get; set; } = string.Empty;
|
||||
private GraphNodeDto? SelectedNode { get; set; }
|
||||
private bool _isLoading = true;
|
||||
private string _errorMessage = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IdentityService.OnStateInvalidated += HandleStateInvalidatedAsync;
|
||||
await SyncService.InitializeAsync();
|
||||
SyncService.OnProgressReceived += HandleProgressReceivedAsync;
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
_errorMessage = string.Empty;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
if (!BookId.HasValue || BookId.Value == Guid.Empty)
|
||||
{
|
||||
var profileResult = await IdentityService.GetProfileAsync();
|
||||
if (profileResult.IsSuccess && profileResult.Value.LastReadBook != null)
|
||||
{
|
||||
BookId = profileResult.Value.LastReadBook.Id;
|
||||
}
|
||||
}
|
||||
|
||||
if (BookId.HasValue && BookId.Value != Guid.Empty)
|
||||
{
|
||||
var result = await ConceptsMapService.GetConceptsMapAsync(BookId.Value);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Nodes = result.Value.Nodes;
|
||||
LastReadBlockId = result.Value.LastReadBlockId;
|
||||
|
||||
if (Nodes.Any())
|
||||
{
|
||||
SelectedNode = Nodes.FirstOrDefault(n => IsUnlocked(n.Id)) ?? Nodes.First();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = result.Errors.FirstOrDefault()?.Message ?? "Brak odpowiedzi od serwera.";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Błąd podczas pobierania danych: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsUnlocked(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId)) return false;
|
||||
var nodeSeq = SegmentIdParser.Parse(nodeId);
|
||||
|
||||
var minNodeSeq = Nodes.Any() ? Nodes.Min(n => SegmentIdParser.Parse(n.Id)) : 0;
|
||||
if (nodeSeq == minNodeSeq) return true;
|
||||
|
||||
if (string.IsNullOrEmpty(LastReadBlockId)) return false;
|
||||
|
||||
var progressSeq = SegmentIdParser.Parse(LastReadBlockId);
|
||||
return nodeSeq <= progressSeq;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void HandleNodeSelected(GraphNodeDto node)
|
||||
{
|
||||
SelectedNode = node;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void GoBackToLibrary()
|
||||
{
|
||||
NavigationManager.NavigateTo("/library");
|
||||
}
|
||||
|
||||
private void GoToReader()
|
||||
{
|
||||
if (BookId.HasValue)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/reader/{BookId.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private void GoToSelectedChapter()
|
||||
{
|
||||
if (BookId.HasValue && SelectedNode != null)
|
||||
{
|
||||
var chapterIndex = SegmentIdParser.Parse(SelectedNode.Id);
|
||||
NavigationManager.NavigateTo($"/reader/{BookId.Value}?chapter={chapterIndex}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStateInvalidatedAsync()
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await LoadDataAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleProgressReceivedAsync(string pageId, DateTime timestamp)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
LastReadBlockId = pageId;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync;
|
||||
SyncService.OnProgressReceived -= HandleProgressReceivedAsync;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user