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:
2026-05-26 17:46:56 +00:00
committed by Marek Jaisński
parent a0bf6c15f4
commit 72905aa119
34 changed files with 2560 additions and 1173 deletions
@@ -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;
}
}