From 59074a05a0b7020022236dabf0d9e884e98b39f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 25 Apr 2026 16:16:36 +0200 Subject: [PATCH] feat: implement epub service, navigation service, and global error boundary with updated reader UI layouts --- .gitignore | 3 + .../Abstractions/Services/IEpubService.cs | 9 ++ .../Queries/Reader/GetReaderPageQuery.cs | 2 +- .../Reader/GetReaderPageQueryHandler.cs | 20 +-- .../Queries/Reader/ViewModels.cs | 6 +- .../DependencyInjection.cs | 1 + .../NexusReader.Infrastructure.csproj | 4 + .../Services/EpubService.cs | 149 ++++++++++++++++++ .../Molecules/IntelligenceToolbar.razor.css | 39 +++-- .../Components/Organisms/ReaderCanvas.razor | 37 ++++- .../Organisms/ReaderCanvas.razor.css | 2 +- .../Components/Organisms/ReaderFooter.razor | 40 ++++- .../Organisms/ReaderFooter.razor.css | 99 +++++++++--- .../Layout/MainLayout.razor | 2 +- .../Layout/MainLayout.razor.css | 54 +++++-- src/NexusReader.UI.Shared/Routes.razor | 40 ++++- src/NexusReader.UI.Shared/Routes.razor.css | 83 ++++++++++ .../Services/IReaderNavigationService.cs | 15 ++ .../Services/ReaderNavigationService.cs | 47 ++++++ src/NexusReader.UI.Shared/wwwroot/app.css | 26 +++ src/NexusReader.Web.Client/Program.cs | 41 ++--- .../Services/WasmEpubService.cs | 38 +++++ src/NexusReader.Web.New/Program.cs | 126 ++++++++------- 23 files changed, 726 insertions(+), 157 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Services/IEpubService.cs create mode 100644 src/NexusReader.Infrastructure/Services/EpubService.cs create mode 100644 src/NexusReader.UI.Shared/Routes.razor.css create mode 100644 src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs create mode 100644 src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs create mode 100644 src/NexusReader.Web.Client/Services/WasmEpubService.cs diff --git a/.gitignore b/.gitignore index ac13e2d..7360c09 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ Thumbs.db # Project specific .gemini/ *.log +*.epub + +.fake \ No newline at end of file diff --git a/src/NexusReader.Application/Abstractions/Services/IEpubService.cs b/src/NexusReader.Application/Abstractions/Services/IEpubService.cs new file mode 100644 index 0000000..d6ff680 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IEpubService.cs @@ -0,0 +1,9 @@ +using FluentResults; +using NexusReader.Application.Queries.Reader; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IEpubService +{ + Task> GetEpubContentAsync(int chapterIndex); +} diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs index b97aa27..132a3f4 100644 --- a/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs @@ -2,4 +2,4 @@ using NexusReader.Application.Abstractions.Messaging; namespace NexusReader.Application.Queries.Reader; -public record GetReaderPageQuery : IQuery; +public record GetReaderPageQuery(int ChapterIndex = 0) : IQuery; diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs index 28f040d..d12cd5c 100644 --- a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs @@ -1,20 +1,20 @@ using FluentResults; using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Queries.Reader; internal sealed class GetReaderPageQueryHandler : IQueryHandler { - public Task> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) - { - var blocks = new List - { - new TextSegmentBlock("renesans-intro", "Renesans, nazywany również odrodzeniem, to epoka w historii kultury europejskiej, która zapoczątkowała odejście od średniowiecznego teocentryzmu na rzecz humanizmu. Narodził się we Włoszech, a dokładnie we Florencji, w XV wieku, skąd promieniował na całą Europę."), - new TextSegmentBlock("medyceusze", "Głównym mecenasem sztuki i nauki we Florencji był potężny ród Medyceuszy. To dzięki ich wsparciu miasto stało się kolebką nowożytnej myśli, gromadząc wokół siebie najwybitniejsze umysły tamtych czasów."), - new AiActionTriggerBlock("da-vinci-ai", "Leonardo da Vinci był jednym z najważniejszych twórców tego okresu. Czy chciałbyś dowiedzieć się więcej o jego najważniejszych wynalazkach, czy wolisz sprawdzić swoją dotychczasową wiedzę?", new List { "Pokaż więcej", "Rozwiąż quiz" }), - new TextSegmentBlock("leonardo-detail", "Człowiek renesansu, uosabiany właśnie przez Leonarda, był wszechstronnie wykształcony. Interesował się sztuką, inżynierią, anatomią i filozofią, stawiając jednostkę w centrum wszechświata.") - }; + private readonly IEpubService _epubService; - return Task.FromResult(Result.Ok(new ReaderPageViewModel(blocks))); + public GetReaderPageQueryHandler(IEpubService epubService) + { + _epubService = epubService; + } + + public async Task> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) + { + return await _epubService.GetEpubContentAsync(request.ChapterIndex); } } diff --git a/src/NexusReader.Application/Queries/Reader/ViewModels.cs b/src/NexusReader.Application/Queries/Reader/ViewModels.cs index fb06490..25f2395 100644 --- a/src/NexusReader.Application/Queries/Reader/ViewModels.cs +++ b/src/NexusReader.Application/Queries/Reader/ViewModels.cs @@ -1,7 +1,11 @@ +using System.Text.Json.Serialization; + namespace NexusReader.Application.Queries.Reader; +[JsonDerivedType(typeof(TextSegmentBlock), "text")] +[JsonDerivedType(typeof(AiActionTriggerBlock), "trigger")] public abstract record ContentBlock(string Id); public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id); public record AiActionTriggerBlock(string Id, string Dialogue, List ActionOptions) : ContentBlock(Id); -public record ReaderPageViewModel(List Blocks); +public record ReaderPageViewModel(List Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle); diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index bcb1f70..4a41272 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -9,6 +9,7 @@ public static class DependencyInjection public static IServiceCollection AddInfrastructure(this IServiceCollection services) { services.AddTransient(); + services.AddTransient(); return services; } } diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index f79590e..efc1082 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -4,6 +4,10 @@ + + + + net10.0 enable diff --git a/src/NexusReader.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs new file mode 100644 index 0000000..6a60222 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -0,0 +1,149 @@ +using System.Text; +using System.Text.RegularExpressions; +using FluentResults; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.Queries.Reader; +using VersOne.Epub; + +namespace NexusReader.Infrastructure.Services; + +public class EpubService : IEpubService +{ + private const string EpubPath = "wwwroot/assets/book.epub"; + private const int WordThreshold = 1000; + + public async Task> GetEpubContentAsync(int chapterIndex) + { + try + { + // Path handling: Recursive search upwards to find the asset in development or production + var relativePath = Path.Combine("wwwroot", "assets", "book.epub"); + string? fullPath = null; + var searchPaths = new List(); + + var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); + while (currentDir != null) + { + var checkPath1 = Path.Combine(currentDir.FullName, relativePath); + var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New", relativePath); + + searchPaths.Add(checkPath1); + if (File.Exists(checkPath1)) { fullPath = checkPath1; break; } + + searchPaths.Add(checkPath2); + if (File.Exists(checkPath2)) { fullPath = checkPath2; break; } + + currentDir = currentDir.Parent; + } + + if (fullPath == null) + { + return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}"); + } + + EpubBook book = await EpubReader.ReadBookAsync(fullPath); + var blocks = new List(); + int totalWordCount = 0; + int blockCounter = 0; + + if (book.ReadingOrder == null || !book.ReadingOrder.Any()) + { + return Result.Fail("The EPUB has no readable content files in ReadingOrder."); + } + + // Ensure index is within bounds + if (chapterIndex < 0 || chapterIndex >= book.ReadingOrder.Count) + { + chapterIndex = 0; // Default to first chapter + } + + var chapter = book.ReadingOrder[chapterIndex]; + var chapterTitle = chapter.FilePath ?? $"Chapter {chapterIndex + 1}"; + + var paragraphs = ExtractParagraphs(chapter.Content); + foreach (var p in paragraphs) + { + var sanitizedContent = SanitizeParagraph(p); + if (string.IsNullOrWhiteSpace(sanitizedContent)) continue; + + // Requirement: Each paragraph mapped to its own TextSegmentBlock + blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent)); + + int wordsInP = CountWords(sanitizedContent); + totalWordCount += wordsInP; + + // Requirement: Smart Injection after 1000 words + if (totalWordCount >= WordThreshold) + { + blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}")); + totalWordCount = 0; + } + } + + // End of chapter section trigger + if (blocks.Any() && blocks.Last() is not AiActionTriggerBlock) + { + blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}")); + } + + return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, book.ReadingOrder.Count, chapterTitle)); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Failed to process EPUB: {ex.Message}").CausedBy(ex)); + } + } + + private List ExtractParagraphs(string html) + { + var paragraphs = new List(); + // Match

tags and their content + var matches = Regex.Matches(html, @"]*>(.*?)

", RegexOptions.IgnoreCase | RegexOptions.Singleline); + foreach (Match match in matches) + { + paragraphs.Add(match.Groups[1].Value); + } + + // Fallback: split by double newlines if no

tags found + if (paragraphs.Count == 0) + { + var bodyMatch = Regex.Match(html, @"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); + var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html; + paragraphs = content.Split(new[] { "
", "
", "\n\n" }, StringSplitOptions.RemoveEmptyEntries).ToList(); + } + + return paragraphs; + } + + private string SanitizeParagraph(string html) + { + // 1. Remove