Files
Nexus.Reader/src/NexusReader.UI.Shared/Pages/MyBooks.razor
T
Antigravity 1d6862016d feat(recommendations): implement contextual recommendation engine (#76)
Resolves #75

### Description
This pull request implements a smart, Native AOT-compliant contextual recommendation engine for the desktop dashboard to drive user retention and cross-book monetization.

### Key Changes
1. **Application Layer**:
   - Declared `IUserReadingStateStore` interface to decouple reading state discovery.
   - Added `IVectorSearchStore.SearchGlobalExcludeAsync(...)` to abstract semantic similarity searches with exclusions.
   - Defined `GetContextualRecommendationsQuery` and response DTOs (`ContextualRecommendationResponse`, `RecommendationDto`).
2. **Infrastructure Layer**:
   - Implemented `UserReadingStateStore` using EF Core with DbContext pooling.
   - Implemented `SearchGlobalExcludeAsync` in `VectorSearchStore` to construct gRPC Qdrant filters (excluding the active book ID) and fetch `bookTitle` and `chapterTitle` from point payloads.
   - Implemented `GetContextualRecommendationsQueryHandler` using clean abstractions.
3. **Web & Serialization Layer**:
   - Mapped `GET /api/recommendations` endpoint.
   - Registered types in `AppJsonContext.cs` for AOT-compliant JSON serialization.
4. **Verification**:
   - Added complete unit test coverage in `GetContextualRecommendationsQueryTests.cs`. All 30 unit tests pass.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #76
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-06 13:38:48 +00:00

172 lines
6.1 KiB
Plaintext

@page "/my-books"
@attribute [Authorize]
@implements IDisposable
@using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Services
@using Microsoft.Extensions.Logging
@using System.Net.Http.Json
@inject HttpClient Http
@inject IReaderNavigationService ReaderNavigation
@inject ILibraryStateService LibraryStateService
@inject ILogger<MyBooks> Logger
<div class="my-books-page">
<header class="my-books-header">
<div class="header-title-section">
<h1>Moje Książki</h1>
<p class="subtitle">Twoje aktywne lektury i postępy w nauce z Nexus AI</p>
</div>
<AuthorizeView Roles="Admin, ContentManager">
<button class="btn-nexus add-book-trigger" @onclick="() => _isModalOpen = true">
<span class="btn-icon">+</span> Dodaj E-book
</button>
</AuthorizeView>
</header>
<BookIngestionModal @bind-IsOpen="_isModalOpen" @bind-IsOpen:after="RefreshLibrary" />
<div class="my-books-content">
@if (_isLoading)
{
<div class="my-books-loading-container">
<div class="loader-card">
<div class="spinner-glow small"></div>
<span class="loader-text">Wczytywanie biblioteki...</span>
</div>
<div class="loading-grid">
@for (int i = 0; i < 3; i++)
{
<div class="skeleton-card">
<div class="skeleton-cover"></div>
<div class="skeleton-details">
<div class="skeleton-line title"></div>
<div class="skeleton-line author"></div>
<div class="skeleton-line progress"></div>
</div>
</div>
}
</div>
</div>
}
else if (_books == null || !_books.Any())
{
<div class="empty-state-container">
<div class="empty-icon-pulse">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
</div>
<h3>Pusta biblioteka</h3>
<p>Nie masz jeszcze żadnych książek na swojej półce. Przejdź do katalogu, aby rozpocząć kurs.</p>
<a href="/catalog" class="btn-nexus catalog-btn">Przeglądaj Katalog</a>
</div>
}
else
{
<div class="books-grid">
@foreach (var book in _books)
{
<div class="book-card" @onclick="() => OpenBook(book.Id)">
<div class="book-cover-container">
<img src="@(book.CoverUrl ?? "https://api.dicebear.com/7.x/identicon/svg?seed=" + book.Title)" alt="@book.Title" class="book-cover" />
<div class="cover-overlay">
<span class="read-action">Czytaj teraz</span>
</div>
</div>
<div class="book-details">
<h3 class="book-title" title="@book.Title">@book.Title</h3>
<p class="book-author">@book.Author.Name</p>
@if (book.Progress > 0)
{
<div class="book-progress-section">
<div class="progress-bar">
<div class="progress-fill" style="width: @(book.Progress.ToString("F0"))%"></div>
</div>
<span class="progress-text">Postęp: @(book.Progress.ToString("F0"))% (@book.LastChapter)</span>
</div>
}
else
{
<span class="new-badge">Nowa</span>
}
<div class="card-actions">
<button class="btn-nexus primary-accent-btn">
KONTYNUUJ KURS
</button>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
@code {
private bool _isModalOpen;
private bool _isLoading = true;
private List<LastReadBookDto>? _books;
protected override void OnInitialized()
{
LibraryStateService.OnBooksChanged += HandleBooksChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadBooksAsync();
}
}
private void HandleBooksChanged()
{
_ = InvokeAsync(LoadBooksAsync);
}
private async Task LoadBooksAsync()
{
_isLoading = true;
StateHasChanged();
try
{
_books = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
_isLoading = false;
}
catch (Exception ex)
{
Logger.LogError(ex, "[MyBooks] Failed to load books.");
if (OperatingSystem.IsBrowser())
{
_isLoading = false;
}
}
finally
{
StateHasChanged();
}
}
private async Task RefreshLibrary()
{
await LoadBooksAsync();
}
private void OpenBook(Guid bookId)
{
ReaderNavigation.NavigateToBook(bookId);
}
public void Dispose()
{
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
}
}