From a6a5fc683c39c01462c1e77c1cd729f5dfb0bb98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 10 May 2026 20:00:14 +0200 Subject: [PATCH 01/37] feat: Add Book Ingestion Modal for local EPUB metadata extraction (fixes #33) --- .../Abstractions/Services/IEpubService.cs | 2 + .../Queries/Reader/LocalEpubMetadata.cs | 7 + .../Services/EpubService.cs | 15 ++ .../Organisms/BookIngestionModal.razor | 149 +++++++++++++ .../Organisms/BookIngestionModal.razor.css | 202 ++++++++++++++++++ src/NexusReader.UI.Shared/Pages/Library.razor | 9 +- .../NexusReader.Web.Client.csproj | 1 + .../Services/WasmEpubService.cs | 15 ++ 8 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css diff --git a/src/NexusReader.Application/Abstractions/Services/IEpubService.cs b/src/NexusReader.Application/Abstractions/Services/IEpubService.cs index 188988c..4b288c0 100644 --- a/src/NexusReader.Application/Abstractions/Services/IEpubService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IEpubService.cs @@ -1,9 +1,11 @@ using FluentResults; using NexusReader.Application.Queries.Reader; +using System.IO; namespace NexusReader.Application.Abstractions.Services; public interface IEpubService { Task> GetEpubContentAsync(int chapterIndex, string? userId = null); + Task> ExtractMetadataAsync(Stream epubStream); } diff --git a/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs new file mode 100644 index 0000000..be21c38 --- /dev/null +++ b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs @@ -0,0 +1,7 @@ +namespace NexusReader.Application.Queries.Reader; + +public record LocalEpubMetadata( + string Title, + string Author, + byte[]? CoverImage = null +); diff --git a/src/NexusReader.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs index b516625..5e6cb2d 100644 --- a/src/NexusReader.Infrastructure/Services/EpubService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -215,4 +215,19 @@ public class EpubService : IEpubService } return null; } + public async Task> ExtractMetadataAsync(Stream epubStream) + { + try + { + using var bookRef = await EpubReader.OpenBookAsync(epubStream); + var title = bookRef.Title ?? "Unknown Title"; + var author = bookRef.Author ?? "Unknown Author"; + byte[]? cover = await bookRef.ReadCoverAsync(); + return Result.Ok(new LocalEpubMetadata(title, author, cover)); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex)); + } + } } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor new file mode 100644 index 0000000..3784239 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -0,0 +1,149 @@ +@using Microsoft.AspNetCore.Components.Forms +@using NexusReader.Application.Abstractions.Services +@using NexusReader.Application.Queries.Reader +@inject IEpubService EpubService +@inject ILogger Logger + +@if (IsOpen) +{ + +} + +@code { + [Parameter] + public bool IsOpen { get; set; } + + [Parameter] + public EventCallback IsOpenChanged { get; set; } + + private bool _isDragging; + private bool IsParsing { get; set; } + private LocalEpubMetadata? Metadata { get; set; } + private string? ErrorMessage { get; set; } + + // Allow up to 50 MB + private const long MaxFileSize = 50 * 1024 * 1024; + + private async Task CloseModal() + { + IsOpen = false; + Reset(); + await IsOpenChanged.InvokeAsync(false); + } + + private void Reset() + { + IsParsing = false; + Metadata = null; + ErrorMessage = null; + _isDragging = false; + } + + private void OnDragEnter() => _isDragging = true; + private void OnDragLeave() => _isDragging = false; + + private async Task HandleFileSelected(InputFileChangeEventArgs e) + { + _isDragging = false; + var file = e.File; + + if (file == null) return; + + if (!file.Name.EndsWith(".epub", StringComparison.OrdinalIgnoreCase)) + { + ErrorMessage = "Only .epub files are supported."; + return; + } + + ErrorMessage = null; + IsParsing = true; + StateHasChanged(); + + try + { + using var stream = file.OpenReadStream(MaxFileSize); + + // In Blazor WASM, we might need to copy to memory stream first for synchronous parsing if the parser doesn't stream well over interop + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + var result = await EpubService.ExtractMetadataAsync(memoryStream); + + if (result.IsSuccess) + { + Metadata = result.Value; + } + else + { + ErrorMessage = result.Errors.FirstOrDefault()?.Message ?? "Failed to parse EPUB."; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error uploading EPUB"); + ErrorMessage = "An unexpected error occurred."; + } + finally + { + IsParsing = false; + StateHasChanged(); + } + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css new file mode 100644 index 0000000..5b44ad5 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css @@ -0,0 +1,202 @@ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.3s ease-out; +} + +.modal-content { + background-color: #121212; + border: 1px solid rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.3); + box-shadow: 0 0 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.1); + border-radius: 12px; + width: 90%; + max-width: 600px; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + position: relative; + overflow: hidden; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + margin: 0; + font-family: var(--nexus-font-sans); + color: var(--nexus-text); + font-size: 1.5rem; +} + +.close-btn { + background: none; + border: none; + color: var(--nexus-text-muted, #888); + cursor: pointer; + transition: color 0.2s; +} + +.close-btn:hover { + color: var(--nexus-accent, #00ffaa); +} + +.modal-body { + min-height: 250px; + display: flex; + flex-direction: column; + justify-content: center; +} + +/* Upload State */ +.upload-state { + flex: 1; + display: flex; +} + +.drop-zone { + flex: 1; + border: 2px dashed rgba(255, 255, 255, 0.1); + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.3s ease; + background: rgba(255, 255, 255, 0.02); +} + +.drop-zone:hover, .upload-state.drag-over .drop-zone { + border-color: var(--nexus-accent, #00ffaa); + background: rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.05); +} + +.drop-zone-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: var(--nexus-text-muted, #888); +} + +.drop-zone-content svg { + color: var(--nexus-accent, #00ffaa); + opacity: 0.8; +} + +.drop-zone-content p { + margin: 0; + font-size: 1.1rem; + color: var(--nexus-text); +} + +.file-input { + display: none; +} + +/* Parsing State */ +.parsing-state { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + position: relative; + overflow: hidden; +} + +.shimmer::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 50%; + height: 100%; + background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.05), transparent); + animation: shimmer 2s infinite; +} + +.shimmer-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2); + border-top-color: var(--nexus-accent, #00ffaa); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.parsing-state p { + color: var(--nexus-text); + font-family: var(--nexus-font-mono, monospace); + font-size: 0.9rem; + letter-spacing: 1px; +} + +/* Metadata State */ +.metadata-state { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.metadata-info { + text-align: center; +} + +.metadata-info h3 { + margin: 0 0 0.5rem 0; + color: var(--nexus-text); + font-size: 1.25rem; +} + +.metadata-info .author { + margin: 0; + color: var(--nexus-text-muted, #888); +} + +.actions { + display: flex; + gap: 1rem; + justify-content: center; +} + +.error-message { + margin-top: 1rem; + color: #ff5555; + text-align: center; + font-size: 0.9rem; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes shimmer { + 100% { left: 200%; } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/NexusReader.UI.Shared/Pages/Library.razor b/src/NexusReader.UI.Shared/Pages/Library.razor index 412c9ca..0d771cc 100644 --- a/src/NexusReader.UI.Shared/Pages/Library.razor +++ b/src/NexusReader.UI.Shared/Pages/Library.razor @@ -1,16 +1,19 @@ @page "/library" @attribute [Authorize] +@using NexusReader.UI.Shared.Components.Organisms

Biblioteka

- + [+] Add New Book
+ +

Twoja kolekcja książek i dokumentów pojawi się tutaj wkrótce.

@@ -51,3 +54,7 @@ opacity: 0.6; } + +@code { + private bool _isModalOpen; +} diff --git a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj index 8046dc7..c6d2c16 100644 --- a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj +++ b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj @@ -13,6 +13,7 @@ + diff --git a/src/NexusReader.Web.Client/Services/WasmEpubService.cs b/src/NexusReader.Web.Client/Services/WasmEpubService.cs index 00431ed..d9b24a3 100644 --- a/src/NexusReader.Web.Client/Services/WasmEpubService.cs +++ b/src/NexusReader.Web.Client/Services/WasmEpubService.cs @@ -35,4 +35,19 @@ public class WasmEpubService : IEpubService return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex)); } } + public async Task> ExtractMetadataAsync(Stream epubStream) + { + try + { + using var bookRef = await VersOne.Epub.EpubReader.OpenBookAsync(epubStream); + var title = bookRef.Title ?? "Unknown Title"; + var author = bookRef.Author ?? "Unknown Author"; + byte[]? cover = await bookRef.ReadCoverAsync(); + return Result.Ok(new LocalEpubMetadata(title, author, cover)); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex)); + } + } } -- 2.52.0 From ac5a50c0142271b882d194a3f0ed6671e1707063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 10 May 2026 20:06:15 +0200 Subject: [PATCH 02/37] fix: resolve InputFile destruction exception during stream read --- .../Organisms/BookIngestionModal.razor | 54 +++++++++---------- .../Organisms/BookIngestionModal.razor.css | 13 ++++- src/TestEpub/TestEpub.csproj | 9 ++++ 3 files changed, 46 insertions(+), 30 deletions(-) create mode 100644 src/TestEpub/TestEpub.csproj diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index 3784239..6188c12 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -16,18 +16,16 @@