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] 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,