From 4fd66052ea06b00209ac9c5d0aa5a2bbd47a8bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 26 May 2026 14:25:00 +0200 Subject: [PATCH 1/4] feat: implement premium gamified Concepts Map dashboard, custom skill-tree, and secure tenant gating --- .../Concepts/BookConceptsMapResultDto.cs | 10 + .../Concepts/GetBookConceptsMapQuery.cs | 9 + .../GetBookConceptsMapQueryHandler.cs | 114 +++++ .../Components/Organisms/ConceptsMap.razor | 114 +++++ .../Organisms/ConceptsMap.razor.css | 235 +++++++++ .../Pages/ConceptsDashboard.razor | 306 +++++++++++ .../Pages/ConceptsDashboard.razor.css | 475 ++++++++++++++++++ src/NexusReader.Web/Program.cs | 23 + 8 files changed, 1286 insertions(+) create mode 100644 src/NexusReader.Application/Queries/Concepts/BookConceptsMapResultDto.cs create mode 100644 src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQuery.cs create mode 100644 src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor create mode 100644 src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css diff --git a/src/NexusReader.Application/Queries/Concepts/BookConceptsMapResultDto.cs b/src/NexusReader.Application/Queries/Concepts/BookConceptsMapResultDto.cs new file mode 100644 index 0000000..6615b4a --- /dev/null +++ b/src/NexusReader.Application/Queries/Concepts/BookConceptsMapResultDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using NexusReader.Application.Queries.Graph; + +namespace NexusReader.Application.Queries.Concepts; + +public record BookConceptsMapResultDto( + [property: JsonPropertyName("nodes")] List Nodes, + [property: JsonPropertyName("lastReadBlockId")] string LastReadBlockId +); diff --git a/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQuery.cs b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQuery.cs new file mode 100644 index 0000000..ef2bfcb --- /dev/null +++ b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQuery.cs @@ -0,0 +1,9 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Queries.Concepts; + +public record GetBookConceptsMapQuery( + Guid BookId, + string UserId, + string TenantId +) : IQuery; diff --git a/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs new file mode 100644 index 0000000..dab036b --- /dev/null +++ b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentResults; +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Queries.Graph; +using NexusReader.Data.Persistence; + +namespace NexusReader.Application.Queries.Concepts; + +internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler +{ + private readonly IDbContextFactory _dbContextFactory; + + public GetBookConceptsMapQueryHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task> Handle(GetBookConceptsMapQuery request, CancellationToken cancellationToken) + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + // 1. Fetch user to extract reading progress (LastReadPageId) + var user = await dbContext.Users + .Where(u => u.Id == request.UserId) + .Select(u => new { u.LastReadPageId }) + .FirstOrDefaultAsync(cancellationToken); + + if (user == null) + { + return Result.Fail("User not found."); + } + + var lastReadBlockId = user.LastReadPageId ?? string.Empty; + + // 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant + var units = await dbContext.KnowledgeUnits + .Where(k => k.EbookId == request.BookId && + (k.TenantId == request.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))) + .OrderBy(k => k.CreatedAt) + .ToListAsync(cancellationToken); + + var nodes = new List(); + + foreach (var unit in units) + { + // Only process units representing sections or conceptual milestones (usually starting with "seg-") + if (string.IsNullOrEmpty(unit.Id) || !unit.Id.StartsWith("seg-")) + { + continue; + } + + string label = unit.Id; + string group = "concept"; + string summary = unit.Content; + var keyTerms = new List(); + + if (!string.IsNullOrEmpty(unit.MetadataJson)) + { + try + { + using var doc = JsonDocument.Parse(unit.MetadataJson); + if (doc.RootElement.TryGetProperty("label", out var labelProp)) + label = labelProp.GetString() ?? label; + if (doc.RootElement.TryGetProperty("group", out var groupProp)) + group = groupProp.GetString() ?? group; + if (doc.RootElement.TryGetProperty("summary", out var summaryProp)) + summary = summaryProp.GetString() ?? summary; + if (doc.RootElement.TryGetProperty("key_terms", out var ktProp) && ktProp.ValueKind == JsonValueKind.Array) + { + foreach (var term in ktProp.EnumerateArray()) + { + if (term.GetString() is string s) + keyTerms.Add(s); + } + } + } + catch + { + // Fallback to defaults + } + } + + nodes.Add(new GraphNodeDto( + Id: unit.Id, + Label: label, + Group: group, + Description: unit.Content, + Type: unit.Type.ToString(), + Summary: summary, + KeyTerms: keyTerms + )); + } + + // Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment + var sortedNodes = nodes + .OrderBy(n => ParseSegmentNumber(n.Id)) + .ToList(); + + return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId)); + } + + private static int ParseSegmentNumber(string? id) + { + if (string.IsNullOrEmpty(id)) return 0; + var digits = new string(id.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out var val) ? val : 0; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor new file mode 100644 index 0000000..f95e716 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor @@ -0,0 +1,114 @@ +@using NexusReader.Application.Queries.Graph +@using NexusReader.UI.Shared.Components.Atoms + +
+ @if (Nodes == null || !Nodes.Any()) + { +
+ +

Brak wygenerowanej mapy pojęć dla tej książki.

+
+ } + else + { +
+ @for (int i = 0; i < Nodes.Count; i++) + { + var index = i; + var node = Nodes[index]; + var isUnlocked = IsUnlocked(node.Id); + var isSelected = SelectedNode?.Id == node.Id; + + var showTrack = index < Nodes.Count - 1; + var isNextUnlocked = showTrack && IsUnlocked(Nodes[index + 1].Id); + +
+ +
+
+ @if (isUnlocked) + { +
+ + } + else + { + + } +
+ + @if (showTrack) + { +
+ } +
+ +
+
+ @node.Id.ToUpper() + @if (isUnlocked) + { + Odblokowane + } + else + { + Zablokowane + } +
+

@node.Label

+

@GetShortDescription(node.Description)

+
+
+ } +
+ } +
+ +@code { + [Parameter] public List Nodes { get; set; } = new(); + [Parameter] public string LastReadBlockId { get; set; } = string.Empty; + [Parameter] public EventCallback OnNodeSelected { get; set; } + [Parameter] public GraphNodeDto? SelectedNode { get; set; } + + private bool IsUnlocked(string nodeId) + { + if (string.IsNullOrEmpty(nodeId)) return false; + + var nodeSeq = ParseSegmentNumber(nodeId); + + // Always unlock the very first segment so the user has a starting node + var minNodeSeq = Nodes.Any() ? Nodes.Min(n => ParseSegmentNumber(n.Id)) : 0; + if (nodeSeq == minNodeSeq) return true; + + if (string.IsNullOrEmpty(LastReadBlockId)) + { + return false; + } + + var progressSeq = ParseSegmentNumber(LastReadBlockId); + return nodeSeq <= progressSeq; + } + + private static int ParseSegmentNumber(string? id) + { + if (string.IsNullOrEmpty(id)) return 0; + var digits = new string(id.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out var val) ? val : 0; + } + + private async Task HandleNodeClick(GraphNodeDto node) + { + if (OnNodeSelected.HasDelegate) + { + await OnNodeSelected.InvokeAsync(node); + } + } + + private string GetShortDescription(string? desc) + { + if (string.IsNullOrEmpty(desc)) return "Brak opisu."; + if (desc.Length <= 110) return desc; + return desc[..107] + "..."; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css new file mode 100644 index 0000000..94bb76e --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css @@ -0,0 +1,235 @@ +.concepts-map { + width: 100%; + max-height: 72vh; + overflow-y: auto; + padding: 1.5rem; + box-sizing: border-box; +} + +/* Scrollbar Customization for modern aesthetic */ +.concepts-map::-webkit-scrollbar { + width: 6px; +} +.concepts-map::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.02); +} +.concepts-map::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} +.concepts-map::-webkit-scrollbar-thumb:hover { + background: var(--nexus-neon); +} + +.empty-map-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + background: rgba(255, 255, 255, 0.02); + border: 1px dashed rgba(255, 255, 255, 0.08); + border-radius: 12px; + color: rgba(255, 255, 255, 0.4); + text-align: center; +} + +.empty-map-state .dim-icon { + margin-bottom: 1rem; + color: rgba(255, 255, 255, 0.2); +} + +.timeline-container { + display: flex; + flex-direction: column; + gap: 0; + position: relative; + padding-left: 0.5rem; +} + +.timeline-step { + display: flex; + flex-direction: row; + gap: 1.5rem; + padding: 1rem; + border-radius: 12px; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + margin-bottom: 0.5rem; +} + +.timeline-step:hover { + background: rgba(255, 255, 255, 0.02); + transform: translateX(4px); +} + +.timeline-step.unlocked:hover { + border-color: rgba(0, 243, 255, 0.15); + box-shadow: 0 4px 20px rgba(0, 243, 255, 0.05); +} + +.timeline-step.selected { + background: rgba(0, 243, 255, 0.04); + border-color: var(--nexus-neon); + box-shadow: 0 0 15px rgba(0, 243, 255, 0.1); +} + +.node-connector-wrapper { + display: flex; + flex-direction: column; + align-items: center; + width: 32px; + position: relative; + flex-shrink: 0; +} + +.node-circle { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 2; + transition: all 0.3s ease; + background: #0d0d0d; +} + +.unlocked .node-circle { + border: 2px solid var(--nexus-neon); + color: var(--nexus-neon); + box-shadow: 0 0 10px rgba(0, 243, 255, 0.3); +} + +.locked .node-circle { + border: 2px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.2); +} + +.node-glow { + position: absolute; + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--nexus-neon); + opacity: 0.15; + filter: blur(4px); + z-index: -1; + animation: pulse-glow 2s infinite ease-in-out; +} + +@keyframes pulse-glow { + 0% { transform: scale(1); opacity: 0.15; } + 50% { transform: scale(1.25); opacity: 0.3; } + 100% { transform: scale(1); opacity: 0.15; } +} + +.vertical-track { + width: 2px; + position: absolute; + top: 32px; + bottom: -18px; /* Extends to link to next node circle */ + z-index: 1; + transition: all 0.3s ease; +} + +.track-active { + background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 243, 255, 0.2)); + box-shadow: 0 0 6px rgba(0, 243, 255, 0.2); +} + +.track-inactive { + background: rgba(255, 255, 255, 0.08); +} + +.node-content { + flex-grow: 1; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 8px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + transition: all 0.3s ease; + backdrop-filter: blur(4px); +} + +.timeline-step.selected .node-content { + background: rgba(255, 255, 255, 0.03); + border-color: rgba(0, 243, 255, 0.2); +} + +.node-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.segment-tag { + font-family: 'Outfit', sans-serif; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.4); +} + +.unlocked .segment-tag { + color: var(--nexus-neon); +} + +.badge { + font-size: 0.7rem; + padding: 0.2rem 0.5rem; + border-radius: 12px; + font-weight: 500; +} + +.badge-unlocked { + background: rgba(0, 243, 255, 0.08); + color: var(--nexus-neon); + border: 1px solid rgba(0, 243, 255, 0.2); +} + +.badge-locked { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.3); + border: 1px solid rgba(255, 255, 255, 0.03); +} + +.node-title { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: #fff; + transition: color 0.2s ease; +} + +.timeline-step.unlocked:hover .node-title { + color: var(--nexus-neon); +} + +.locked .node-title { + color: rgba(255, 255, 255, 0.4); +} + +.node-desc { + margin: 0; + font-size: 0.8rem; + line-height: 1.4; + color: rgba(255, 255, 255, 0.5); +} + +.locked .node-desc { + color: rgba(255, 255, 255, 0.3); +} + +.check-icon { + color: var(--nexus-neon); +} + +.lock-icon { + color: rgba(255, 255, 255, 0.2); +} diff --git a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor new file mode 100644 index 0000000..bdd75e6 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor @@ -0,0 +1,306 @@ +@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 +@inject HttpClient Http +@inject NavigationManager NavigationManager +@inject IIdentityService IdentityService +@inject ISyncService SyncService +@attribute [Authorize] +@implements IDisposable + +Mapa Pojęć | Nexus Reader + +
+ @if (_isLoading) + { +
+
+ +
+
+

Inicjalizowanie mapy pojęć...

+
+ } + else if (!string.IsNullOrEmpty(_errorMessage)) + { +
+ +

Wystąpił Błąd

+

@_errorMessage

+ +
+ } + else if (!BookId.HasValue || BookId.Value == Guid.Empty || Nodes == null || !Nodes.Any()) + { +
+ +

Brak Aktywnych Książek

+

Nie wybrano żadnej książki lub ta książka nie ma jeszcze wygenerowanej mapy pojęć przez Nexus AI.

+ Przejdź do Biblioteki +
+ } + else + { +
+
+ +
+
+

Mapa Pojęć

+ Interaktywna ścieżka rozwoju Twoich postępów nauki +
+
+ +
+
+ +
+ +
+
+

Ścieżka Rozwoju Wiedzy

+
+
+ +
+
+ + +
+ @if (SelectedNode == null) + { +
+
+ +
+

Wybierz węzeł na mapie

+

Kliknij dowolne pojęcie z lewego panelu, aby uruchomić głęboką analizę i prześledzić szczegóły wygenerowane przez sztuczną inteligencję.

+
+ } + else + { + var isSelectedNodeUnlocked = IsUnlocked(SelectedNode.Id); + +
+
+
+ @SelectedNode.Id.ToUpper() + @if (isSelectedNodeUnlocked) + { + + Odblokowane + + } + else + { + + Zablokowane + + } +
+

@SelectedNode.Label

+
+ +
+ @if (!isSelectedNodeUnlocked) + { +
+ +
+ Ten etap jest zablokowany +

Kontynuuj czytanie książki, aby odblokować to pojęcie. Po przeczytaniu rozdziału, postęp zsynchronizuje się automatycznie.

+
+
+ } + + + + + + @if (SelectedNode.KeyTerms != null && SelectedNode.KeyTerms.Any()) + { + + } +
+ + +
+ } +
+
+ } +
+ +@code { + [Parameter] public Guid? BookId { get; set; } + + private List 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 Http.GetFromJsonAsync($"api/book/{BookId}/concepts-map"); + if (result != null) + { + Nodes = result.Nodes; + LastReadBlockId = result.LastReadBlockId; + + if (Nodes.Any()) + { + SelectedNode = Nodes.FirstOrDefault(n => IsUnlocked(n.Id)) ?? Nodes.First(); + } + } + else + { + _errorMessage = "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 = ParseSegmentNumber(nodeId); + + var minNodeSeq = Nodes.Any() ? Nodes.Min(n => ParseSegmentNumber(n.Id)) : 0; + if (nodeSeq == minNodeSeq) return true; + + if (string.IsNullOrEmpty(LastReadBlockId)) return false; + + var progressSeq = ParseSegmentNumber(LastReadBlockId); + return nodeSeq <= progressSeq; + } + + private static int ParseSegmentNumber(string? id) + { + if (string.IsNullOrEmpty(id)) return 0; + var digits = new string(id.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out var val) ? val : 0; + } + + 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 = ParseSegmentNumber(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 void Dispose() + { + IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync; + SyncService.OnProgressReceived -= HandleProgressReceivedAsync; + } +} diff --git a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css new file mode 100644 index 0000000..03f1203 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css @@ -0,0 +1,475 @@ +.concepts-dashboard-container { + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 2rem 1.5rem; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1.5rem; + min-height: calc(100vh - 80px); +} + +/* Header Section */ +.dashboard-header { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 2rem; + padding: 1.25rem 2rem; + background: rgba(20, 20, 20, 0.35); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 16px; + backdrop-filter: blur(12px); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2); +} + +.header-back .btn-back { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + color: #aaa; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + transition: all 0.2s ease; +} + +.header-back .btn-back:hover { + border-color: var(--nexus-neon); + color: var(--nexus-neon); + background: rgba(0, 243, 255, 0.05); + box-shadow: 0 0 10px rgba(0, 243, 255, 0.1); +} + +.header-title h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #fff; +} + +.header-title .subtitle { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.4); +} + +.header-actions .btn-action { + background: linear-gradient(135deg, rgba(0, 243, 255, 0.1), rgba(0, 243, 255, 0.05)); + border: 1px solid var(--nexus-neon); + color: var(--nexus-neon); + padding: 0.6rem 1.2rem; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 0 15px rgba(0, 243, 255, 0.15); +} + +.header-actions .btn-action:hover { + background: var(--nexus-neon); + color: #000; + box-shadow: 0 0 25px var(--nexus-neon); + transform: translateY(-1px); +} + +/* Grid Layout */ +.concepts-dashboard { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 1.5rem; + height: 72vh; +} + +/* Glass Panels */ +.glass-panel { + background: rgba(13, 13, 13, 0.45); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 16px; + backdrop-filter: blur(16px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.pane-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.pane-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #fff; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.pane-content { + flex-grow: 1; + overflow: hidden; +} + +/* Loading, Error and Empty States */ +.loading-state, .error-state, .empty-dashboard-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + margin: auto; + width: 100%; + max-width: 480px; + box-sizing: border-box; +} + +.preloader-robot { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; +} + +.neon-pulse { + color: var(--nexus-neon); + filter: drop-shadow(0 0 10px var(--nexus-neon)); + animation: robot-pulse 2s infinite ease-in-out; +} + +@keyframes robot-pulse { + 0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); } + 50% { transform: scale(1.08); filter: drop-shadow(0 0 25px var(--nexus-neon)); } + 100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); } +} + +.scan-line { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background: var(--nexus-neon); + box-shadow: 0 0 15px var(--nexus-neon); + animation: scan 2s infinite linear; + opacity: 0.8; +} + +@keyframes scan { + 0% { top: 0; } + 50% { top: 100%; } + 100% { top: 0; } +} + +.loading-text { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.7); + margin-top: 1rem; + letter-spacing: 0.05em; +} + +.error-icon, .dim-icon { + margin-bottom: 1.5rem; +} + +.error-icon { + color: #ff4a4a; + filter: drop-shadow(0 0 8px rgba(255, 74, 74, 0.4)); +} + +.dim-icon { + color: rgba(255, 255, 255, 0.15); +} + +.empty-dashboard-state h2, .error-state h3 { + color: #fff; + margin: 0 0 0.75rem 0; + font-weight: 600; +} + +.empty-dashboard-state p, .error-state p { + color: rgba(255, 255, 255, 0.45); + font-size: 0.88rem; + line-height: 1.5; + margin: 0 0 2rem 0; +} + +/* Workspace Panels */ +.workspace-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-grow: 1; + padding: 3rem; + text-align: center; + color: rgba(255, 255, 255, 0.4); +} + +.empty-glowing-brain { + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(0, 243, 255, 0.04); + border: 1px solid rgba(0, 243, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + box-shadow: 0 0 20px rgba(0, 243, 255, 0.05); +} + +.workspace-empty h4 { + margin: 0 0 0.75rem 0; + color: #fff; + font-size: 1.1rem; + font-weight: 600; +} + +.workspace-empty p { + font-size: 0.85rem; + line-height: 1.5; + max-width: 320px; + margin: 0; +} + +.workspace-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.workspace-header { + padding: 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.node-meta { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.node-id { + font-family: 'Outfit', sans-serif; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--nexus-neon); +} + +.badge { + font-size: 0.7rem; + padding: 0.2rem 0.55rem; + border-radius: 12px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.badge-unlocked { + background: rgba(0, 243, 255, 0.08); + color: var(--nexus-neon); + border: 1px solid rgba(0, 243, 255, 0.2); +} + +.badge-locked { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.4); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.workspace-title { + margin: 0; + font-size: 1.4rem; + font-weight: 700; + color: #fff; +} + +.workspace-body { + flex-grow: 1; + overflow-y: auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Scrollbar customization for workspace body */ +.workspace-body::-webkit-scrollbar { + width: 6px; +} +.workspace-body::-webkit-scrollbar-track { + background: transparent; +} +.workspace-body::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 3px; +} +.workspace-body::-webkit-scrollbar-thumb:hover { + background: var(--nexus-neon); +} + +.locked-warning { + display: flex; + flex-direction: row; + gap: 1rem; + background: rgba(255, 171, 0, 0.04); + border: 1px solid rgba(255, 171, 0, 0.15); + border-radius: 8px; + padding: 1rem; + color: rgba(255, 255, 255, 0.85); +} + +.lock-warning-icon { + color: #ffab00; + flex-shrink: 0; + margin-top: 0.1rem; +} + +.locked-warning strong { + font-size: 0.85rem; + color: #ffab00; + display: block; + margin-bottom: 0.25rem; +} + +.locked-warning p { + margin: 0; + font-size: 0.8rem; + line-height: 1.4; + color: rgba(255, 255, 255, 0.55); +} + +.metadata-section h4 { + margin: 0 0 0.5rem 0; + font-size: 0.85rem; + font-weight: 600; + color: #aaa; + display: flex; + align-items: center; + gap: 0.35rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.section-text { + margin: 0; + font-size: 0.88rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.7); +} + +.summary-box { + background: rgba(255, 255, 255, 0.02); + border-left: 3px solid var(--nexus-neon); + border-radius: 0 8px 8px 0; + padding: 1rem; + margin-top: 0.25rem; +} + +.key-terms-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.term-pill { + font-size: 0.75rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.6); + padding: 0.3rem 0.75rem; + border-radius: 20px; + font-weight: 500; + transition: all 0.2s ease; +} + +.term-pill:hover { + border-color: rgba(0, 243, 255, 0.2); + color: var(--nexus-neon); + background: rgba(0, 243, 255, 0.03); +} + +.workspace-footer { + padding: 1.25rem 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.nexus-btn.primary-neon { + background: linear-gradient(135deg, var(--nexus-neon), #00b8cc); + color: #000; + border: none; + font-weight: 600; + letter-spacing: 0.03em; + padding: 0.75rem 1.5rem; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 15px rgba(0, 243, 255, 0.2); +} + +.nexus-btn.primary-neon:hover { + box-shadow: 0 6px 25px rgba(0, 243, 255, 0.4); + transform: translateY(-1px); +} + +.nexus-btn.secondary-neon { + background: transparent; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.1); + font-weight: 600; + padding: 0.75rem 1.5rem; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; +} + +.nexus-btn.secondary-neon:hover { + border-color: var(--nexus-neon); + color: var(--nexus-neon); + box-shadow: 0 0 10px rgba(0, 243, 255, 0.1); +} + +@media (max-width: 1024px) { + .concepts-dashboard { + grid-template-columns: 1fr; + height: auto; + } + .concepts-dashboard-container { + padding: 1rem; + } + .dashboard-header { + grid-template-columns: 1fr; + text-align: center; + gap: 1rem; + } + .header-back, .header-actions { + display: flex; + justify-content: center; + } +} diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 9e10a59..6f71195 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -7,6 +7,7 @@ using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Queries.User; using NexusReader.Application.Commands.Library; using NexusReader.Application.Queries.Library; +using NexusReader.Application.Queries.Concepts; using MediatR; using NexusReader.Web.Client.Services; using NexusReader.UI.Shared.Services; @@ -369,6 +370,28 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator return Results.BadRequest(errorMsg); }).RequireAuthorization(); +app.MapGet("/api/book/{bookId:guid}/concepts-map", async ( + Guid bookId, + ClaimsPrincipal user, + IMediator mediator) => +{ + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + + if (string.IsNullOrEmpty(userId)) + { + return Results.Unauthorized(); + } + + var result = await mediator.Send(new GetBookConceptsMapQuery(bookId, userId, tenantId)); + if (result.IsFailed) + { + return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Failed to fetch concepts map."); + } + + return Results.Ok(result.Value); +}).RequireAuthorization(); + app.MapPost("/api/StripeWebhook", async ( HttpContext context, UserManager userManager, -- 2.52.0 From 44c4ad090349b68e1a54e37d89cf92af57645b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 26 May 2026 16:48:46 +0200 Subject: [PATCH 2/4] feat: introduce IConceptsMapService abstraction with server and WASM implementations and update knowledge processing flow --- .../Services/IConceptsMapService.cs | 9 +++ .../Components/Organisms/ReaderCanvas.razor | 4 +- .../Pages/ConceptsDashboard.razor | 13 ++-- .../Services/KnowledgeCoordinator.cs | 4 +- src/NexusReader.Web.Client/Program.cs | 1 + .../Services/WasmConceptsMapService.cs | 36 +++++++++++ src/NexusReader.Web/Program.cs | 6 ++ .../Services/ServerConceptsMapService.cs | 59 +++++++++++++++++++ .../Services/ServerIdentityService.cs | 15 ++++- 9 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Services/IConceptsMapService.cs create mode 100644 src/NexusReader.Web.Client/Services/WasmConceptsMapService.cs create mode 100644 src/NexusReader.Web/Services/ServerConceptsMapService.cs diff --git a/src/NexusReader.Application/Abstractions/Services/IConceptsMapService.cs b/src/NexusReader.Application/Abstractions/Services/IConceptsMapService.cs new file mode 100644 index 0000000..77f21c0 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IConceptsMapService.cs @@ -0,0 +1,9 @@ +using FluentResults; +using NexusReader.Application.Queries.Concepts; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IConceptsMapService +{ + Task> GetConceptsMapAsync(Guid bookId); +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index 1646a98..066e336 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -103,7 +103,7 @@ _isInteractive = true; if (ViewModel != null) { - await Coordinator.ProcessFullPageAsync(GetFullPageContent()); + await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId); } } @@ -246,7 +246,7 @@ if (_isInteractive) { - await Coordinator.ProcessFullPageAsync(GetFullPageContent()); + await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId); } } else diff --git a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor index bdd75e6..e8df5f5 100644 --- a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor @@ -7,7 +7,8 @@ @using NexusReader.Application.Queries.Graph @using NexusReader.Application.Queries.Concepts @using System.Net.Http.Json -@inject HttpClient Http +@using NexusReader.Application.Abstractions.Services +@inject IConceptsMapService ConceptsMapService @inject NavigationManager NavigationManager @inject IIdentityService IdentityService @inject ISyncService SyncService @@ -203,11 +204,11 @@ if (BookId.HasValue && BookId.Value != Guid.Empty) { - var result = await Http.GetFromJsonAsync($"api/book/{BookId}/concepts-map"); - if (result != null) + var result = await ConceptsMapService.GetConceptsMapAsync(BookId.Value); + if (result.IsSuccess) { - Nodes = result.Nodes; - LastReadBlockId = result.LastReadBlockId; + Nodes = result.Value.Nodes; + LastReadBlockId = result.Value.LastReadBlockId; if (Nodes.Any()) { @@ -216,7 +217,7 @@ } else { - _errorMessage = "Brak odpowiedzi od serwera."; + _errorMessage = result.Errors.FirstOrDefault()?.Message ?? "Brak odpowiedzi od serwera."; } } } diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index 9489616..1986436 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -75,7 +75,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable } } - public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global") + public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null) { if (string.IsNullOrWhiteSpace(fullContent)) return; @@ -87,7 +87,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable try { - var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId); + var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId); if (result.IsSuccess) { var packet = result.Value; diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index 9eed57e..a011bc3 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -36,6 +36,7 @@ builder.Services.AddCascadingAuthenticationState(); // AI & Content Services builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddTransient(); builder.Services.AddHttpClient("NexusAPI", client => diff --git a/src/NexusReader.Web.Client/Services/WasmConceptsMapService.cs b/src/NexusReader.Web.Client/Services/WasmConceptsMapService.cs new file mode 100644 index 0000000..41d10fa --- /dev/null +++ b/src/NexusReader.Web.Client/Services/WasmConceptsMapService.cs @@ -0,0 +1,36 @@ +using System.Net.Http.Json; +using FluentResults; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.Queries.Concepts; + +namespace NexusReader.Web.Client.Services; + +public class WasmConceptsMapService : IConceptsMapService +{ + private readonly HttpClient _httpClient; + + public WasmConceptsMapService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> GetConceptsMapAsync(Guid bookId) + { + try + { + var response = await _httpClient.GetAsync($"api/book/{bookId}/concepts-map"); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(); + return result != null ? Result.Ok(result) : Result.Fail("Błąd deserializacji mapy pojęć."); + } + + var errorContent = await response.Content.ReadAsStringAsync(); + return Result.Fail($"Błąd serwera ({response.StatusCode}): {errorContent}"); + } + catch (Exception ex) + { + return Result.Fail(new Error("Błąd sieci przy pobieraniu mapy pojęć.").CausedBy(ex)); + } + } +} diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 6f71195..70cdb68 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -74,6 +74,7 @@ builder.Services.AddHttpClient("NexusAPI", (sp, client) => builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddApplication(); @@ -87,6 +88,11 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( // Authorization Policies builder.Services.AddScoped(); builder.Services.AddAuthorizationBuilder() + .SetDefaultPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder( + IdentityConstants.ApplicationScheme, + IdentityConstants.BearerScheme) + .RequireAuthenticatedUser() + .Build()) .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName)) .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); diff --git a/src/NexusReader.Web/Services/ServerConceptsMapService.cs b/src/NexusReader.Web/Services/ServerConceptsMapService.cs new file mode 100644 index 0000000..5dd01e7 --- /dev/null +++ b/src/NexusReader.Web/Services/ServerConceptsMapService.cs @@ -0,0 +1,59 @@ +using System.Security.Claims; +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Http; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.Queries.Concepts; + +namespace NexusReader.Web.Services; + +public class ServerConceptsMapService : IConceptsMapService +{ + private readonly IMediator _mediator; + private readonly AuthenticationStateProvider _authStateProvider; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ServerConceptsMapService( + IMediator mediator, + AuthenticationStateProvider authStateProvider, + IHttpContextAccessor httpContextAccessor) + { + _mediator = mediator; + _authStateProvider = authStateProvider; + _httpContextAccessor = httpContextAccessor; + } + + public async Task> GetConceptsMapAsync(Guid bookId) + { + try + { + var authState = await _authStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user == null || !user.Identity?.IsAuthenticated == true) + { + user = _httpContextAccessor.HttpContext?.User; + } + + if (user == null || !user.Identity?.IsAuthenticated == true) + { + return Result.Fail("Użytkownik nie jest uwierzytelniony."); + } + + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + + if (string.IsNullOrEmpty(userId)) + { + return Result.Fail("Nie znaleziono identyfikatora użytkownika."); + } + + return await _mediator.Send(new GetBookConceptsMapQuery(bookId, userId, tenantId)); + } + catch (Exception ex) + { + return Result.Fail(new Error("Błąd pobierania mapy pojęć na serwerze.").CausedBy(ex)); + } + } +} diff --git a/src/NexusReader.Web/Services/ServerIdentityService.cs b/src/NexusReader.Web/Services/ServerIdentityService.cs index 2a1aaff..6396f19 100644 --- a/src/NexusReader.Web/Services/ServerIdentityService.cs +++ b/src/NexusReader.Web/Services/ServerIdentityService.cs @@ -9,6 +9,7 @@ using NexusReader.Application.Queries.User; using MediatR; using NexusReader.Application.Constants; using NexusReader.Application.Abstractions.Services; +using Microsoft.AspNetCore.Components.Authorization; namespace NexusReader.Web.Services; @@ -19,6 +20,7 @@ public class ServerIdentityService : IIdentityService private readonly IHttpContextAccessor _httpContextAccessor; private readonly IMediator _mediator; private readonly INativeStorageService _storageService; + private readonly AuthenticationStateProvider _authStateProvider; public event Func? OnStateInvalidated; @@ -27,13 +29,15 @@ public class ServerIdentityService : IIdentityService SignInManager signInManager, IHttpContextAccessor httpContextAccessor, IMediator mediator, - INativeStorageService storageService) + INativeStorageService storageService, + AuthenticationStateProvider authStateProvider) { _userManager = userManager; _signInManager = signInManager; _httpContextAccessor = httpContextAccessor; _mediator = mediator; _storageService = storageService; + _authStateProvider = authStateProvider; } public async Task LoginAsync(string email, string password, bool rememberMe = false) @@ -107,7 +111,14 @@ public class ServerIdentityService : IIdentityService public async Task> GetProfileAsync() { - var user = _httpContextAccessor.HttpContext?.User; + var authState = await _authStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user == null || !user.Identity?.IsAuthenticated == true) + { + user = _httpContextAccessor.HttpContext?.User; + } + if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated."); var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); -- 2.52.0 From 209344cfa00196362f72501fffe73981c02d3352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 26 May 2026 17:05:46 +0200 Subject: [PATCH 3/4] refactor: centralize UI design tokens and implement reusable glass-panel and button styles --- .agent/skills/nexus-ui-engine/SKILL.md | 17 +- .../Layout/AuthLayout.razor | 18 - .../Layout/AuthLayout.razor.css | 15 + .../Pages/Account/Profile.razor | 2 +- .../Pages/Account/Profile.razor.css | 38 +- .../Pages/ConceptsDashboard.razor | 10 +- .../Pages/ConceptsDashboard.razor.css | 98 +---- .../Pages/Intelligence.razor | 408 +----------------- .../Pages/Intelligence.razor.css | 368 ++++++++++++++++ src/NexusReader.UI.Shared/Pages/Library.razor | 399 +---------------- .../Pages/Library.razor.css | 368 ++++++++++++++++ .../Pages/SerilogDemo.razor | 254 +---------- .../Pages/SerilogDemo.razor.css | 246 +++++++++++ .../Pages/Settings.razor | 49 +-- .../Pages/Settings.razor.css | 74 ++++ src/NexusReader.UI.Shared/wwwroot/app.css | 43 +- .../wwwroot/css/nexus-auth.css | 7 +- 17 files changed, 1161 insertions(+), 1253 deletions(-) create mode 100644 src/NexusReader.UI.Shared/Layout/AuthLayout.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/Intelligence.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/Library.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/SerilogDemo.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/Settings.razor.css diff --git a/.agent/skills/nexus-ui-engine/SKILL.md b/.agent/skills/nexus-ui-engine/SKILL.md index 4c5eba8..988b731 100644 --- a/.agent/skills/nexus-ui-engine/SKILL.md +++ b/.agent/skills/nexus-ui-engine/SKILL.md @@ -12,14 +12,22 @@ description: Design System & Component rules for Blazor - **Styling & Isolation:** - Mandatory use of scoped CSS (`.razor.css`). + - Strict compliance: Zero inline ` diff --git a/src/NexusReader.UI.Shared/Layout/AuthLayout.razor.css b/src/NexusReader.UI.Shared/Layout/AuthLayout.razor.css new file mode 100644 index 0000000..dbc4235 --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/AuthLayout.razor.css @@ -0,0 +1,15 @@ +.nexus-auth-shell { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: radial-gradient(circle at center, #1b202e 0%, #0f1115 100%) !important; + display: flex; + justify-content: center; + align-items: center; + z-index: 99999; + margin: 0; + padding: 0; + overflow: hidden; +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor index 0b21a66..6511897 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor @@ -89,7 +89,7 @@ Node: @_profile.TenantId.ToString().ToUpper()
- + +
} else if (!BookId.HasValue || BookId.Value == Guid.Empty || Nodes == null || !Nodes.Any()) @@ -43,14 +43,14 @@

Brak Aktywnych Książek

Nie wybrano żadnej książki lub ta książka nie ma jeszcze wygenerowanej mapy pojęć przez Nexus AI.

- Przejdź do Biblioteki + Przejdź do Biblioteki } else {
- @@ -60,7 +60,7 @@ Interaktywna ścieżka rozwoju Twoich postępów nauki
- @@ -156,7 +156,7 @@
- - @code { private string _question = string.Empty; private string _selectedBookId = string.Empty; diff --git a/src/NexusReader.UI.Shared/Pages/Intelligence.razor.css b/src/NexusReader.UI.Shared/Pages/Intelligence.razor.css new file mode 100644 index 0000000..0ddaf47 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor.css @@ -0,0 +1,368 @@ +.intelligence-page { + padding: 2rem; + max-width: 1100px; + margin: 0 auto; + height: calc(100vh - 100px); + display: flex; + flex-direction: column; + animation: fadeIn 0.5s ease-out; +} + +.intelligence-header { + margin-bottom: 1.5rem; + flex-shrink: 0; +} + +.neon-glow-text { + font-family: var(--nexus-font-sans); + font-size: 2.5rem; + font-weight: 800; + margin: 0 0 0.25rem 0; + background: linear-gradient(135deg, var(--nexus-neon) 0%, rgba(0, 255, 153, 0.7) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.2)); +} + +.subtitle { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.6); + margin: 0; +} + +.intelligence-layout { + flex-grow: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} + +.chat-thread-container { + flex-grow: 1; + overflow-y: auto; + padding: 2rem; + display: flex; + flex-direction: column; +} + +/* Custom Scrollbars */ +.chat-thread-container::-webkit-scrollbar { + width: 6px; +} +.chat-thread-container::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.01); +} +.chat-thread-container::-webkit-scrollbar-thumb { + background: rgba(0, 255, 153, 0.2); + border-radius: 4px; +} +.chat-thread-container::-webkit-scrollbar-thumb:hover { + background: rgba(0, 255, 153, 0.4); +} + +.chat-bubbles-scroll { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; +} + +.message-row { + display: flex; + gap: 1rem; + width: 100%; + max-width: 85%; + animation: bubble-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.user-row { + align-self: flex-end; + flex-direction: row-reverse; +} + +.ai-row { + align-self: flex-start; +} + +.message-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; +} + +.user-row .message-avatar { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 0 10px rgba(255, 255, 255, 0.1); +} + +.ai-row .message-avatar { + background: linear-gradient(135deg, #005f38 0%, #004024 100%); + color: #e6fffa; + border: 1px solid rgba(0, 255, 153, 0.4); + box-shadow: 0 0 10px rgba(0, 255, 153, 0.25); +} + +.message-bubble { + padding: 1.25rem 1.5rem; + border-radius: var(--radius-lg); + position: relative; + line-height: 1.6; + font-size: 0.975rem; +} + +.user-bubble { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #ffffff; + border-top-right-radius: 4px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.ai-bubble { + background: rgba(10, 20, 30, 0.55); + border: 1px solid rgba(0, 255, 153, 0.2); + color: #e2e8f0; + border-top-left-radius: 4px; + box-shadow: 0 4px 15px rgba(0, 255, 153, 0.05); + flex-grow: 1; +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + font-size: 0.75rem; + opacity: 0.6; +} + +.sender-name { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.message-time { + font-family: monospace; +} + +.message-content { + word-break: break-word; +} + +/* Paragraph Spacing & Markdown */ +.message-content p { + margin: 0 0 1rem 0; +} +.message-content p:last-child { + margin-bottom: 0; +} + +.nexus-code-block { + background: rgba(0, 0, 0, 0.4) !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + border-radius: var(--radius-sm); + padding: 1rem; + margin: 1rem 0; + overflow-x: auto; + font-family: 'Fira Code', monospace; + font-size: 0.85rem; + color: #a7f3d0; +} + +.nexus-inline-code { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 0.15rem 0.35rem; + font-family: monospace; + font-size: 0.9em; + color: #f472b6; /* Light pink for inline code */ +} + +/* Pending State Bubble */ +.pending-bubble { + border-color: rgba(0, 255, 153, 0.4); + box-shadow: 0 0 15px rgba(0, 255, 153, 0.1); +} + +.typing-indicator { + display: flex; + gap: 4px; + align-items: center; + margin-bottom: 0.5rem; +} + +.typing-indicator span { + width: 8px; + height: 8px; + background: var(--nexus-neon); + border-radius: 50%; + display: inline-block; + animation: typing-bounce 1.4s infinite ease-in-out both; +} + +.typing-indicator span:nth-child(1) { animation-delay: -0.32s; } +.typing-indicator span:nth-child(2) { animation-delay: -0.16s; } + +.loading-label { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.5); + font-style: italic; +} + +/* Input Controls */ +.chat-input-controls { + padding: 1.5rem 2rem 2rem 2rem; + background: rgba(0, 0, 0, 0.2); + border-top: 1px solid rgba(255, 255, 255, 0.05); + flex-shrink: 0; +} + +.input-panel-wrapper { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.scope-bar { + display: flex; + align-items: center; +} + +.scope-selector { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.5); +} + +.nexus-select { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #ffffff; + padding: 0.35rem 2rem 0.35rem 0.75rem; + border-radius: var(--radius-sm); + outline: none; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.3s ease; + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 0.85em; +} + +.nexus-select:focus { + border-color: var(--nexus-neon); + box-shadow: 0 0 8px rgba(0, 255, 153, 0.2); +} + +.input-field-group { + display: flex; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + padding: 0.35rem; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.input-field-group:focus-within { + border-color: var(--nexus-neon); + background: rgba(0, 255, 153, 0.01); + box-shadow: 0 0 15px rgba(0, 255, 153, 0.15); +} + +.nexus-input { + flex-grow: 1; + background: transparent; + border: none; + color: #ffffff; + font-size: 1rem; + outline: none; + padding: 0.5rem 1rem; +} + +.nexus-input::placeholder { + color: rgba(255, 255, 255, 0.35); +} + +.search-btn { + width: 46px; + height: 46px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; +} + +.welcome-state { + text-align: center; + color: rgba(255, 255, 255, 0.5); + padding: 4rem 2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.welcome-icon { + color: rgba(0, 255, 153, 0.4); + margin-bottom: 1.5rem; + filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.2)); + animation: pulse 2.5s infinite alternate; +} + +.welcome-state h3 { + color: #ffffff; + font-size: 1.5rem; + margin: 0 0 0.75rem 0; +} + +.welcome-state p { + max-width: 550px; + margin: 0; + font-size: 0.95rem; + line-height: 1.6; +} + +.btn-spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top-color: #000000; + animation: spin 0.8s linear infinite; +} + +/* Keyframe Animations */ +@keyframes bubble-fade-in { + 0% { opacity: 0; transform: translateY(10px) scale(0.98); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes typing-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} + +@keyframes pulse { + 0% { transform: scale(0.96); opacity: 0.8; } + 100% { transform: scale(1.04); opacity: 1; } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/src/NexusReader.UI.Shared/Pages/Library.razor b/src/NexusReader.UI.Shared/Pages/Library.razor index 94d2355..6fc6f0e 100644 --- a/src/NexusReader.UI.Shared/Pages/Library.razor +++ b/src/NexusReader.UI.Shared/Pages/Library.razor @@ -59,7 +59,7 @@

Nie masz jeszcze żadnych książek w swojej kolekcji.

- @@ -106,403 +106,6 @@ - - @code { private bool _isModalOpen; private bool _isLoading = true; diff --git a/src/NexusReader.UI.Shared/Pages/Library.razor.css b/src/NexusReader.UI.Shared/Pages/Library.razor.css new file mode 100644 index 0000000..a6d57b6 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Library.razor.css @@ -0,0 +1,368 @@ +.library-page { + padding: 3rem 2rem; + max-width: 1200px; + margin: 0 auto; + animation: fadeIn 0.6s ease-out; +} + +.library-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 3rem; + flex-wrap: wrap; + gap: 1.5rem; +} + +.header-title-section h1 { + font-family: var(--nexus-font-serif); + font-size: 2.8rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + background: linear-gradient(135deg, var(--nexus-text) 0%, rgba(255, 255, 255, 0.7) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: -0.5px; +} + +.header-title-section .subtitle { + font-size: 1rem; + color: rgba(255, 255, 255, 0.6); + margin: 0; +} + +::deep .add-book-trigger { + background: var(--nexus-neon) !important; + color: #000000 !important; + border: none !important; + box-shadow: 0 4px 15px var(--nexus-primary-glow) !important; + font-weight: 600 !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; + border-radius: var(--radius-md) !important; +} + +::deep .add-book-trigger:hover { + transform: translateY(-2px) !important; + box-shadow: 0 8px 20px rgba(0, 255, 153, 0.5) !important; + filter: brightness(1.1); +} + +.btn-icon { + margin-right: 0.5rem; + font-weight: bold; +} + +/* Books Grid */ +.books-grid, .loading-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 2rem; +} + +.book-card { + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + border-radius: var(--radius-lg); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.book-card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(800px circle at var(--x, 0) var(--y, 0), rgba(255, 255, 255, 0.06), transparent 40%); + opacity: 0; + transition: opacity 0.5s; + pointer-events: none; +} + +.book-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3), 0 0 2px rgba(255, 255, 255, 0.1) inset; + border-color: rgba(0, 255, 153, 0.2); +} + +.book-card:hover::before { + opacity: 1; +} + +.book-cover-container { + position: relative; + height: 380px; + background: rgba(0, 0, 0, 0.2); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.book-cover { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.book-card:hover .book-cover { + transform: scale(1.08); +} + +.cover-overlay { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.6); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + backdrop-filter: blur(4px); +} + +.book-card:hover .cover-overlay { + opacity: 1; +} + +.read-action { + color: #ffffff; + font-weight: 600; + font-size: 1.1rem; + padding: 0.75rem 1.5rem; + border: 2px solid #ffffff; + border-radius: 30px; + transform: translateY(10px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.book-card:hover .read-action { + transform: translateY(0); + background: #ffffff; + color: #0f172a; +} + +.book-details { + padding: 1.5rem; + display: flex; + flex-direction: column; + flex-grow: 1; + background: rgba(15, 23, 42, 0.3); +} + +.book-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: var(--nexus-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--nexus-font-sans); +} + +.book-author { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.5); + margin: 0 0 1rem 0; +} + +.new-badge { + align-self: flex-start; + font-size: 0.75rem; + font-weight: 600; + color: var(--nexus-primary); + background: rgba(0, 255, 153, 0.15); + padding: 0.25rem 0.75rem; + border-radius: 20px; + border: 1px solid rgba(0, 255, 153, 0.3); +} + +/* Book Progress Bar */ +.book-progress-section { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.progress-bar { + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--nexus-neon) 0%, #00ccff 100%); + border-radius: 3px; +} + +.progress-text { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Empty State */ +.empty-state-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 5rem 2rem; + text-align: center; + border-radius: var(--radius-lg); +} + +.empty-icon-pulse { + margin-bottom: 2rem; + color: rgba(255, 255, 255, 0.2); + animation: pulse 3s infinite alternate; +} + +.empty-state-container h3 { + font-family: var(--nexus-font-serif); + font-size: 1.8rem; + margin: 0 0 0.5rem 0; + color: var(--nexus-text); +} + +.empty-state-container p { + color: rgba(255, 255, 255, 0.5); + max-width: 400px; + margin: 0 0 2rem 0; +} + +.restricted-info { + font-size: 0.85rem; + font-style: italic; + color: rgba(255, 255, 255, 0.35) !important; +} + +/* Skeleton Loading */ +.skeleton-card { + border-radius: var(--radius-lg); + overflow: hidden; + height: 480px; + background: rgba(255, 255, 255, 0.02) !important; + border: 1px solid rgba(255, 255, 255, 0.05) !important; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important; + opacity: 0.5; +} + +.skeleton-cover { + height: 380px; + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.04) 75%) !important; + background-size: 200% 100%; + animation: loading 1.5s infinite; +} + +.skeleton-details { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.skeleton-line { + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.04) 75%) !important; + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: 4px; +} + +.skeleton-line.title { + height: 20px; + width: 80%; +} + +.skeleton-line.author { + height: 14px; + width: 50%; +} + +.skeleton-line.progress { + height: 8px; + width: 100%; + margin-top: auto; +} + +.library-loading-container { + position: relative; + width: 100%; +} + +.library-loading-container .loader-card { + position: absolute; + top: 180px; + left: 50%; + transform: translate(-50%, -50%); + z-index: 10; + display: flex; + align-items: center; + gap: 1.25rem; + padding: 1.25rem 2.25rem; + border-radius: 40px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4), 0 0 1px rgba(255, 255, 255, 0.15) inset; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.spinner-glow { + width: 60px; + height: 60px; + border: 3px solid rgba(0, 255, 153, 0.1); + border-radius: 50%; + border-top-color: var(--nexus-neon); + animation: spin 1s cubic-bezier(0.55, 0.055, 0.675, 0.19) infinite; + box-shadow: 0 0 15px rgba(0, 255, 153, 0.2); +} + +.spinner-glow.small { + width: 28px; + height: 28px; + border-width: 2px; +} + +.loader-text { + font-family: var(--nexus-font-sans); + font-weight: 500; + color: #ffffff; + font-size: 0.95rem; + letter-spacing: 0.2px; +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(15px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse { + 0% { transform: scale(0.95); opacity: 0.6; } + 100% { transform: scale(1.05); opacity: 0.9; } +} + +@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes scaleIn { + from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; } + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } +} diff --git a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor index 08c8fde..9de68ef 100644 --- a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor +++ b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor @@ -3,7 +3,7 @@ @inject IJSRuntime JSRuntime
-
+
@@ -19,7 +19,7 @@
-
+

Native .NET Logs (C#)

@@ -42,7 +42,7 @@
-
+

Blazor / JS WebView Logs

@@ -62,7 +62,7 @@
-
+

Pipeline Diagnostics

@@ -88,252 +88,6 @@
- @code { private void LogInfo() diff --git a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor.css b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor.css new file mode 100644 index 0000000..f8c402a --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor.css @@ -0,0 +1,246 @@ +.serilog-demo-container { + padding: 3rem 2rem; + max-width: 1200px; + margin: 0 auto; + animation: fadeIn 0.6s ease-out; +} + +.header-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2rem; + margin-bottom: 2rem; +} + +.header-content { + display: flex; + align-items: center; + gap: 1.5rem; +} + +::deep .header-icon { + color: var(--nexus-neon); + filter: drop-shadow(0 0 8px var(--nexus-primary-glow)); +} + +.header-text h1 { + font-family: var(--nexus-font-sans); + font-size: 1.8rem; + font-weight: 700; + margin: 0; + background: linear-gradient(to right, #ffffff, rgba(255, 255, 255, 0.7)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.subtitle { + margin: 0.25rem 0 0 0; + color: rgba(255, 255, 255, 0.6); + font-size: 0.95rem; +} + +.status-badge { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0, 255, 153, 0.05); + border: 1px solid rgba(0, 255, 153, 0.2); + padding: 0.5rem 1rem; + border-radius: 9999px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-dot.green { + background-color: var(--nexus-neon); + box-shadow: 0 0 8px var(--nexus-neon); +} + +.status-text { + font-size: 0.85rem; + font-weight: 600; + color: var(--nexus-neon); +} + +.demo-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +@media (max-width: 768px) { + .demo-grid { + grid-template-columns: 1fr; + } +} + +.control-card { + border-radius: var(--radius-lg); + padding: 2rem; + transition: all 0.3s ease; +} + +.card-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +::deep .card-icon { + color: var(--nexus-neon); +} + +::deep .js-icon { + color: #eab308; +} + +.card-header h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: #ffffff; +} + +.card-desc { + color: rgba(255, 255, 255, 0.5); + font-size: 0.9rem; + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.btn-group { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-radius: var(--radius-md); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + border: none; +} + +.btn-info { + background-color: rgba(0, 255, 153, 0.05); + color: var(--nexus-neon); + border: 1px solid rgba(0, 255, 153, 0.2) !important; +} + +.btn-info:hover { + background-color: var(--nexus-neon); + color: #000000; + box-shadow: 0 0 15px var(--nexus-primary-glow); +} + +.btn-warning { + background-color: rgba(245, 158, 11, 0.05); + color: #fbbf24; + border: 1px solid rgba(245, 158, 11, 0.2) !important; +} + +.btn-warning:hover { + background-color: #f59e0b; + color: #000000; + box-shadow: 0 0 15px rgba(245, 158, 11, 0.3); +} + +.btn-error { + background-color: rgba(239, 68, 68, 0.05); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.2) !important; +} + +.btn-error:hover { + background-color: #ef4444; + color: white; + box-shadow: 0 0 15px rgba(239, 68, 68, 0.3); +} + +.btn-js-info { + background-color: rgba(234, 179, 8, 0.05); + color: #fef08a; + border: 1px solid rgba(234, 179, 8, 0.2) !important; +} + +.btn-js-info:hover { + background-color: #eab308; + color: #0f172a; + box-shadow: 0 0 15px rgba(234, 179, 8, 0.3); +} + +.btn-js-error { + background-color: rgba(236, 72, 153, 0.05); + color: #fbcfe8; + border: 1px solid rgba(236, 72, 153, 0.2) !important; +} + +.btn-js-error:hover { + background-color: #ec4899; + color: white; + box-shadow: 0 0 15px rgba(236, 72, 153, 0.3); +} + +.config-card { + border-radius: var(--radius-lg); + padding: 2rem; +} + +.config-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-top: 1.5rem; +} + +@media (max-width: 768px) { + .config-grid { + grid-template-columns: 1fr; + } +} + +.config-item { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.config-item .label { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.config-item .value { + font-size: 0.95rem; + color: #cbd5e1; +} + +.code-value { + font-family: var(--nexus-font-mono); + background: rgba(0, 0, 0, 0.2); + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.05); + word-break: break-all; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(15px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor b/src/NexusReader.UI.Shared/Pages/Settings.razor index f19734b..a08c958 100644 --- a/src/NexusReader.UI.Shared/Pages/Settings.razor +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor @@ -5,7 +5,7 @@

Settings

Configure your account and application preferences.

-
+

Diagnostics & System Logs

Inspect native logging infrastructure, trigger custom logs, and trace WebView errors.

@@ -15,50 +15,3 @@
- diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor.css b/src/NexusReader.UI.Shared/Pages/Settings.razor.css new file mode 100644 index 0000000..16b7053 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor.css @@ -0,0 +1,74 @@ +.settings-page { + padding: 3rem 2rem; + max-width: 1200px; + margin: 0 auto; + animation: fadeIn 0.6s ease-out; +} + +h1 { + font-family: var(--nexus-font-serif); + font-size: 2.8rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + background: linear-gradient(135deg, var(--nexus-text) 0%, rgba(255, 255, 255, 0.7) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: -0.5px; +} + +.settings-page > p { + font-size: 1rem; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 3rem; +} + +.settings-section { + padding: 2rem; + margin-top: 1.5rem; + border-radius: var(--radius-lg); + transition: all 0.3s ease; +} + +.settings-section h2 { + font-family: var(--nexus-font-sans); + font-size: 1.35rem; + font-weight: 600; + color: #ffffff; + margin: 0 0 0.75rem 0; +} + +.settings-section p { + color: rgba(255, 255, 255, 0.5); + font-size: 0.95rem; + margin: 0 0 1.5rem 0; + line-height: 1.5; +} + +.diag-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: rgba(0, 255, 153, 0.05); + color: var(--nexus-neon); + border: 1px solid rgba(0, 255, 153, 0.2); + padding: 0.75rem 1.5rem; + border-radius: var(--radius-md); + text-decoration: none; + font-size: 0.9rem; + font-weight: 600; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 0 0 transparent; +} + +.diag-btn:hover { + background: var(--nexus-neon); + color: #000000; + border-color: var(--nexus-neon); + box-shadow: 0 0 15px var(--nexus-primary-glow); + transform: translateY(-2px); +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(15px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/NexusReader.UI.Shared/wwwroot/app.css b/src/NexusReader.UI.Shared/wwwroot/app.css index 8de4576..dc21052 100644 --- a/src/NexusReader.UI.Shared/wwwroot/app.css +++ b/src/NexusReader.UI.Shared/wwwroot/app.css @@ -2,6 +2,7 @@ :root { --nexus-neon: #00ff99; + --nexus-neon-glow: rgba(0, 255, 153, 0.3); --nexus-bg: #121212; --nexus-card: #1a1a1a; --nexus-text: #ffffff; @@ -9,6 +10,17 @@ --nexus-font-sans: 'Inter', sans-serif; --nexus-font-serif: 'Merriweather', serif; + /* Global Semantic Theme Mapping */ + --nexus-primary: var(--nexus-neon); + --nexus-primary-glow: var(--nexus-neon-glow); + --nexus-primary-hover: #00e688; + + /* Standard Layout Tokens */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + /* Safe Area Insets with fallbacks */ --safe-area-inset-top: env(safe-area-inset-top, 0px); --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); @@ -23,7 +35,7 @@ .glass-panel { background: rgba(20, 20, 20, 0.85); /* Darker fallback for readability */ border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 20px; + border-radius: var(--radius-xl); padding: 1.5rem; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } @@ -35,6 +47,35 @@ } } +/* Unified Enterprise Component Constraints */ +.btn-nexus { + font-family: var(--nexus-font-sans); + font-weight: 600; + border-radius: var(--radius-md); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border: none; + text-decoration: none; +} +.btn-nexus-primary { + background: var(--nexus-neon); + color: #000000; +} +.btn-nexus-secondary { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #ffffff; +} +.btn-nexus:hover { + transform: translateY(-2px); + filter: brightness(1.1); + box-shadow: 0 4px 15px var(--nexus-primary-glow); +} + .theme-light { --nexus-bg: var(--nexus-paper); diff --git a/src/NexusReader.UI.Shared/wwwroot/css/nexus-auth.css b/src/NexusReader.UI.Shared/wwwroot/css/nexus-auth.css index 01d40a1..ab7b525 100644 --- a/src/NexusReader.UI.Shared/wwwroot/css/nexus-auth.css +++ b/src/NexusReader.UI.Shared/wwwroot/css/nexus-auth.css @@ -1,9 +1,8 @@ :root { - --nexus-primary: #44ff77; - --nexus-bg: #121418; - --nexus-card-bg: #1c1f24; + --nexus-primary: var(--nexus-neon); + --nexus-card-bg: var(--nexus-card); --nexus-border: rgba(255, 255, 255, 0.08); - --nexus-text-muted: #666; + --nexus-text-muted: #888888; --nexus-text-bright: #e2e8f0; } -- 2.52.0 From f3b02c85843f5b12964c83b75e2940c6f388e063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 26 May 2026 19:42:24 +0200 Subject: [PATCH 4/4] refactor: introduce IConceptsMapReadRepository and SegmentIdParser to improve data access and domain logic encapsulation. --- .../Persistence/IConceptsMapReadRepository.cs | 23 +++++++++ .../GetBookConceptsMapQueryHandler.cs | 40 ++++------------ .../Utilities/SegmentIdParser.cs | 19 ++++++++ .../DependencyInjection.cs | 1 + .../Persistence/ConceptsMapReadRepository.cs | 48 +++++++++++++++++++ .../Components/Organisms/ConceptsMap.razor | 14 ++---- .../Organisms/ConceptsMap.razor.css | 20 ++++---- .../Pages/ConceptsDashboard.razor | 21 ++++---- .../Pages/Library.razor.css | 4 +- .../Pages/SerilogDemo.razor | 13 ++++- .../Pages/Settings.razor | 4 +- .../Pages/Settings.razor.css | 2 +- src/NexusReader.UI.Shared/wwwroot/app.css | 5 ++ .../Services/ServerConceptsMapService.cs | 26 ++++------ .../Services/ServerIdentityService.cs | 4 +- 15 files changed, 157 insertions(+), 87 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs create mode 100644 src/NexusReader.Application/Utilities/SegmentIdParser.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs diff --git a/src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs b/src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs new file mode 100644 index 0000000..e02544f --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs @@ -0,0 +1,23 @@ +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Read-only abstraction for fetching concepts map data. +/// Defined in the Application layer to avoid a direct dependency on EF Core / NexusReader.Data. +/// +public interface IConceptsMapReadRepository +{ + /// + /// Gets the last read page ID for the specified user. + /// + Task GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Gets all knowledge units associated with a book, scoped by tenant. + /// + Task> GetKnowledgeUnitsForBookAsync( + Guid bookId, + string tenantId, + CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs index dab036b..8cdb123 100644 --- a/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs @@ -5,45 +5,30 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentResults; -using Microsoft.EntityFrameworkCore; using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Queries.Graph; -using NexusReader.Data.Persistence; +using NexusReader.Application.Utilities; namespace NexusReader.Application.Queries.Concepts; internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler { - private readonly IDbContextFactory _dbContextFactory; + private readonly IConceptsMapReadRepository _repository; - public GetBookConceptsMapQueryHandler(IDbContextFactory dbContextFactory) + public GetBookConceptsMapQueryHandler(IConceptsMapReadRepository repository) { - _dbContextFactory = dbContextFactory; + _repository = repository; } public async Task> Handle(GetBookConceptsMapQuery request, CancellationToken cancellationToken) { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - // 1. Fetch user to extract reading progress (LastReadPageId) - var user = await dbContext.Users - .Where(u => u.Id == request.UserId) - .Select(u => new { u.LastReadPageId }) - .FirstOrDefaultAsync(cancellationToken); - - if (user == null) - { - return Result.Fail("User not found."); - } - - var lastReadBlockId = user.LastReadPageId ?? string.Empty; + var lastReadPageId = await _repository.GetLastReadPageIdAsync(request.UserId, cancellationToken); + var lastReadBlockId = lastReadPageId ?? string.Empty; // 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant - var units = await dbContext.KnowledgeUnits - .Where(k => k.EbookId == request.BookId && - (k.TenantId == request.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))) - .OrderBy(k => k.CreatedAt) - .ToListAsync(cancellationToken); + var units = await _repository.GetKnowledgeUnitsForBookAsync(request.BookId, request.TenantId, cancellationToken); var nodes = new List(); @@ -99,16 +84,9 @@ internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler ParseSegmentNumber(n.Id)) + .OrderBy(n => SegmentIdParser.Parse(n.Id)) .ToList(); return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId)); } - - private static int ParseSegmentNumber(string? id) - { - if (string.IsNullOrEmpty(id)) return 0; - var digits = new string(id.Where(char.IsDigit).ToArray()); - return int.TryParse(digits, out var val) ? val : 0; - } } diff --git a/src/NexusReader.Application/Utilities/SegmentIdParser.cs b/src/NexusReader.Application/Utilities/SegmentIdParser.cs new file mode 100644 index 0000000..cc0def7 --- /dev/null +++ b/src/NexusReader.Application/Utilities/SegmentIdParser.cs @@ -0,0 +1,19 @@ +namespace NexusReader.Application.Utilities; + +/// +/// Shared utility for parsing numeric segment identifiers from IDs like "seg-42". +/// Centralizes the parsing contract to avoid duplication across handlers and UI components. +/// +public static class SegmentIdParser +{ + /// + /// Extracts the numeric portion from a segment identifier string (e.g., "seg-42" → 42). + /// Returns 0 if the string is null, empty, or contains no digits. + /// + public static int Parse(string? id) + { + if (string.IsNullOrEmpty(id)) return 0; + var digits = new string(id.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out var val) ? val : 0; + } +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 58ea65f..9c39cca 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -121,6 +121,7 @@ public static class DependencyInjection // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) services.AddScoped(); diff --git a/src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs b/src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs new file mode 100644 index 0000000..29062b1 --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; + +namespace NexusReader.Infrastructure.Persistence; + +/// +/// EF Core implementation of . +/// Uses for Blazor-safe scoped context creation. +/// +internal sealed class ConceptsMapReadRepository : IConceptsMapReadRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public ConceptsMapReadRepository(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + /// + public async Task GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var user = await dbContext.Users + .Where(u => u.Id == userId) + .Select(u => new { u.LastReadPageId }) + .FirstOrDefaultAsync(cancellationToken); + + return user?.LastReadPageId; + } + + /// + public async Task> GetKnowledgeUnitsForBookAsync( + Guid bookId, + string tenantId, + CancellationToken cancellationToken = default) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + return await dbContext.KnowledgeUnits + .Where(k => k.EbookId == bookId && + (k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))) + .OrderBy(k => k.CreatedAt) + .ToListAsync(cancellationToken); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor index f95e716..46239da 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor @@ -1,4 +1,5 @@ @using NexusReader.Application.Queries.Graph +@using NexusReader.Application.Utilities @using NexusReader.UI.Shared.Components.Atoms
@@ -75,10 +76,10 @@ { if (string.IsNullOrEmpty(nodeId)) return false; - var nodeSeq = ParseSegmentNumber(nodeId); + var nodeSeq = SegmentIdParser.Parse(nodeId); // Always unlock the very first segment so the user has a starting node - var minNodeSeq = Nodes.Any() ? Nodes.Min(n => ParseSegmentNumber(n.Id)) : 0; + var minNodeSeq = Nodes.Any() ? Nodes.Min(n => SegmentIdParser.Parse(n.Id)) : 0; if (nodeSeq == minNodeSeq) return true; if (string.IsNullOrEmpty(LastReadBlockId)) @@ -86,16 +87,11 @@ return false; } - var progressSeq = ParseSegmentNumber(LastReadBlockId); + var progressSeq = SegmentIdParser.Parse(LastReadBlockId); return nodeSeq <= progressSeq; } - private static int ParseSegmentNumber(string? id) - { - if (string.IsNullOrEmpty(id)) return 0; - var digits = new string(id.Where(char.IsDigit).ToArray()); - return int.TryParse(digits, out var val) ? val : 0; - } + private async Task HandleNodeClick(GraphNodeDto node) { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css index 94bb76e..47220b5 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css @@ -65,14 +65,14 @@ } .timeline-step.unlocked:hover { - border-color: rgba(0, 243, 255, 0.15); - box-shadow: 0 4px 20px rgba(0, 243, 255, 0.05); + border-color: rgba(0, 255, 153, 0.15); + box-shadow: 0 4px 20px rgba(0, 255, 153, 0.05); } .timeline-step.selected { - background: rgba(0, 243, 255, 0.04); + background: rgba(0, 255, 153, 0.04); border-color: var(--nexus-neon); - box-shadow: 0 0 15px rgba(0, 243, 255, 0.1); + box-shadow: 0 0 15px var(--nexus-primary-glow); } .node-connector-wrapper { @@ -100,7 +100,7 @@ .unlocked .node-circle { border: 2px solid var(--nexus-neon); color: var(--nexus-neon); - box-shadow: 0 0 10px rgba(0, 243, 255, 0.3); + box-shadow: 0 0 10px var(--nexus-primary-glow); } .locked .node-circle { @@ -136,8 +136,8 @@ } .track-active { - background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 243, 255, 0.2)); - box-shadow: 0 0 6px rgba(0, 243, 255, 0.2); + background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 255, 153, 0.2)); + box-shadow: 0 0 6px var(--nexus-primary-glow); } .track-inactive { @@ -159,7 +159,7 @@ .timeline-step.selected .node-content { background: rgba(255, 255, 255, 0.03); - border-color: rgba(0, 243, 255, 0.2); + border-color: rgba(0, 255, 153, 0.2); } .node-header { @@ -188,9 +188,9 @@ } .badge-unlocked { - background: rgba(0, 243, 255, 0.08); + background: rgba(0, 255, 153, 0.08); color: var(--nexus-neon); - border: 1px solid rgba(0, 243, 255, 0.2); + border: 1px solid rgba(0, 255, 153, 0.2); } .badge-locked { diff --git a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor index 1d6f464..e4ae704 100644 --- a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor @@ -8,12 +8,13 @@ @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 IDisposable +@implements IAsyncDisposable Mapa Pojęć | Nexus Reader @@ -235,23 +236,18 @@ private bool IsUnlocked(string nodeId) { if (string.IsNullOrEmpty(nodeId)) return false; - var nodeSeq = ParseSegmentNumber(nodeId); + var nodeSeq = SegmentIdParser.Parse(nodeId); - var minNodeSeq = Nodes.Any() ? Nodes.Min(n => ParseSegmentNumber(n.Id)) : 0; + 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 = ParseSegmentNumber(LastReadBlockId); + var progressSeq = SegmentIdParser.Parse(LastReadBlockId); return nodeSeq <= progressSeq; } - private static int ParseSegmentNumber(string? id) - { - if (string.IsNullOrEmpty(id)) return 0; - var digits = new string(id.Where(char.IsDigit).ToArray()); - return int.TryParse(digits, out var val) ? val : 0; - } + private void HandleNodeSelected(GraphNodeDto node) { @@ -276,7 +272,7 @@ { if (BookId.HasValue && SelectedNode != null) { - var chapterIndex = ParseSegmentNumber(SelectedNode.Id); + var chapterIndex = SegmentIdParser.Parse(SelectedNode.Id); NavigationManager.NavigateTo($"/reader/{BookId.Value}?chapter={chapterIndex}"); } } @@ -299,9 +295,10 @@ }); } - public void Dispose() + public ValueTask DisposeAsync() { IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync; SyncService.OnProgressReceived -= HandleProgressReceivedAsync; + return ValueTask.CompletedTask; } } diff --git a/src/NexusReader.UI.Shared/Pages/Library.razor.css b/src/NexusReader.UI.Shared/Pages/Library.razor.css index a6d57b6..3fde1ce 100644 --- a/src/NexusReader.UI.Shared/Pages/Library.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Library.razor.css @@ -31,7 +31,7 @@ margin: 0; } -::deep .add-book-trigger { +.add-book-trigger { background: var(--nexus-neon) !important; color: #000000 !important; border: none !important; @@ -41,7 +41,7 @@ border-radius: var(--radius-md) !important; } -::deep .add-book-trigger:hover { +.add-book-trigger:hover { transform: translateY(-2px) !important; box-shadow: 0 8px 20px rgba(0, 255, 153, 0.5) !important; filter: brightness(1.1); diff --git a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor index 9de68ef..2c6159c 100644 --- a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor +++ b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor @@ -2,13 +2,14 @@ @inject ILogger Logger @inject IJSRuntime JSRuntime +#if DEBUG

Serilog Logging Infrastructure

-

Production-grade diagnostic pipeline for unified native & web logs

+

Production-grade diagnostic pipeline for unified native & web logs

@@ -87,9 +88,18 @@
+#else +
+
+

Diagnostics Unavailable

+

This page is only available in DEBUG builds.

+
+
+#endif @code { +#if DEBUG private void LogInfo() { Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo"); @@ -121,4 +131,5 @@ { await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');"); } +#endif } diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor b/src/NexusReader.UI.Shared/Pages/Settings.razor index a08c958..ae288b8 100644 --- a/src/NexusReader.UI.Shared/Pages/Settings.razor +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor @@ -5,13 +5,15 @@

Settings

Configure your account and application preferences.

+ #if DEBUG
+ #endif
diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor.css b/src/NexusReader.UI.Shared/Pages/Settings.razor.css index 16b7053..7df77b0 100644 --- a/src/NexusReader.UI.Shared/Pages/Settings.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor.css @@ -5,7 +5,7 @@ animation: fadeIn 0.6s ease-out; } -h1 { +.settings-page > h1 { font-family: var(--nexus-font-serif); font-size: 2.8rem; font-weight: 700; diff --git a/src/NexusReader.UI.Shared/wwwroot/app.css b/src/NexusReader.UI.Shared/wwwroot/app.css index dc21052..2238d4d 100644 --- a/src/NexusReader.UI.Shared/wwwroot/app.css +++ b/src/NexusReader.UI.Shared/wwwroot/app.css @@ -73,8 +73,13 @@ .btn-nexus:hover { transform: translateY(-2px); filter: brightness(1.1); +} +.btn-nexus-primary:hover { box-shadow: 0 4px 15px var(--nexus-primary-glow); } +.btn-nexus-secondary:hover { + box-shadow: 0 4px 15px rgba(255, 255, 255, 0.05); +} .theme-light { diff --git a/src/NexusReader.Web/Services/ServerConceptsMapService.cs b/src/NexusReader.Web/Services/ServerConceptsMapService.cs index 5dd01e7..04845e4 100644 --- a/src/NexusReader.Web/Services/ServerConceptsMapService.cs +++ b/src/NexusReader.Web/Services/ServerConceptsMapService.cs @@ -1,8 +1,6 @@ using System.Security.Claims; using FluentResults; using MediatR; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Http; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Queries.Concepts; @@ -11,38 +9,30 @@ namespace NexusReader.Web.Services; public class ServerConceptsMapService : IConceptsMapService { private readonly IMediator _mediator; - private readonly AuthenticationStateProvider _authStateProvider; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IIdentityService _identityService; public ServerConceptsMapService( IMediator mediator, - AuthenticationStateProvider authStateProvider, - IHttpContextAccessor httpContextAccessor) + IIdentityService identityService) { _mediator = mediator; - _authStateProvider = authStateProvider; - _httpContextAccessor = httpContextAccessor; + _identityService = identityService; } public async Task> GetConceptsMapAsync(Guid bookId) { try { - var authState = await _authStateProvider.GetAuthenticationStateAsync(); - var user = authState.User; - - if (user == null || !user.Identity?.IsAuthenticated == true) - { - user = _httpContextAccessor.HttpContext?.User; - } + var profileResult = await _identityService.GetProfileAsync(); - if (user == null || !user.Identity?.IsAuthenticated == true) + if (profileResult.IsFailed) { return Result.Fail("Użytkownik nie jest uwierzytelniony."); } - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var profile = profileResult.Value; + var userId = profile.UserId; + var tenantId = profile.TenantId.ToString(); if (string.IsNullOrEmpty(userId)) { diff --git a/src/NexusReader.Web/Services/ServerIdentityService.cs b/src/NexusReader.Web/Services/ServerIdentityService.cs index 6396f19..56b0061 100644 --- a/src/NexusReader.Web/Services/ServerIdentityService.cs +++ b/src/NexusReader.Web/Services/ServerIdentityService.cs @@ -114,12 +114,12 @@ public class ServerIdentityService : IIdentityService var authState = await _authStateProvider.GetAuthenticationStateAsync(); var user = authState.User; - if (user == null || !user.Identity?.IsAuthenticated == true) + if (user == null || user.Identity?.IsAuthenticated != true) { user = _httpContextAccessor.HttpContext?.User; } - if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated."); + if (user == null || user.Identity?.IsAuthenticated != true) return Result.Fail("Not authenticated."); var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (userId == null) return Result.Fail("User ID not found."); -- 2.52.0