diff --git a/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs b/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs
index eb39b53..a45b420 100644
--- a/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs
+++ b/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs
@@ -1,7 +1,29 @@
namespace NexusReader.Application.Abstractions.Services;
+///
+/// Service for managing ebook and cover file storage.
+///
public interface IBookStorageService
{
+ ///
+ /// Saves an ebook file and returns its relative path/URL.
+ ///
Task SaveEbookAsync(byte[] data, string fileName);
+
+ ///
+ /// Saves an ebook file using a stream and returns its relative path/URL.
+ ///
+ Task SaveEbookAsync(Stream data, string fileName);
+
+ ///
+ /// Saves a cover image and returns its relative path/URL.
+ /// Returns null if no cover data is provided.
+ ///
Task SaveCoverAsync(byte[] data, string fileName);
+
+ ///
+ /// Saves a cover image using a stream and returns its relative path/URL.
+ /// Returns null if no cover data is provided.
+ ///
+ Task SaveCoverAsync(Stream data, string fileName);
}
diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs
index 18c11c4..7026e25 100644
--- a/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs
+++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs
@@ -2,6 +2,15 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Commands.Library;
+///
+/// Command to ingest a new ebook into the library.
+///
+/// The title of the book.
+/// The name of the author.
+/// The raw bytes of the cover image (optional).
+/// The raw bytes of the EPUB file.
+/// The ID of the user owning the book.
+/// The tenant ID for multi-tenant isolation. Defaults to "global" for single-tenant or default usage.
public record IngestEbookCommand(
string Title,
string AuthorName,
diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs
index f01dcd3..0b2727d 100644
--- a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs
+++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs
@@ -23,18 +23,30 @@ public class IngestEbookCommandHandler : IRequestHandler> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
{
+ using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ string epubPath;
+ string? coverUrl;
+
try
{
- using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
-
// 1. Save Files
- var epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub");
- var coverUrl = request.CoverImage != null && request.CoverImage.Length > 0
+ epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub");
+ coverUrl = request.CoverImage != null && request.CoverImage.Length > 0
? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg")
: null;
+ }
+ catch (Exception ex)
+ {
+ return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex));
+ }
+ try
+ {
// 2. Resolve Author
var authorName = string.IsNullOrWhiteSpace(request.AuthorName) ? "Unknown Author" : request.AuthorName.Trim();
+
+ // Use case-insensitive comparison
var author = await context.Authors
.FirstOrDefaultAsync(a => a.Name.ToLower() == authorName.ToLower(), cancellationToken);
@@ -42,8 +54,6 @@ public class IngestEbookCommandHandler : IRequestHandler
+/// Represents metadata extracted from a local EPUB file.
+///
public record LocalEpubMetadata
{
+ ///
+ /// The title of the book.
+ ///
public string Title { get; set; } = string.Empty;
+
+ ///
+ /// The author(s) of the book.
+ ///
public string Author { get; set; } = string.Empty;
+
+ ///
+ /// The raw bytes of the cover image, if available.
+ ///
public byte[]? CoverImage { get; set; }
}
diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs
index 2779061..3484315 100644
--- a/src/NexusReader.Infrastructure/DependencyInjection.cs
+++ b/src/NexusReader.Infrastructure/DependencyInjection.cs
@@ -75,7 +75,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddTransient();
services.AddTransient();
- services.AddScoped();
+ services.AddSingleton();
services.AddAuthorizationCore(options =>
{
diff --git a/src/NexusReader.Infrastructure/Services/BookStorageService.cs b/src/NexusReader.Infrastructure/Services/BookStorageService.cs
index caaed22..58ec6ca 100644
--- a/src/NexusReader.Infrastructure/Services/BookStorageService.cs
+++ b/src/NexusReader.Infrastructure/Services/BookStorageService.cs
@@ -3,6 +3,10 @@ using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Infrastructure.Services;
+///
+/// Infrastructure implementation of book storage using local filesystem.
+/// All paths returned are relative to the web root.
+///
public class BookStorageService : IBookStorageService
{
private readonly IWebHostEnvironment _environment;
@@ -13,38 +17,55 @@ public class BookStorageService : IBookStorageService
}
public async Task SaveEbookAsync(byte[] data, string fileName)
+ {
+ using var stream = new MemoryStream(data);
+ return await SaveEbookAsync(stream, fileName);
+ }
+
+ public async Task SaveEbookAsync(Stream data, string fileName)
{
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
- if (!Directory.Exists(uploadsFolder))
- {
- Directory.CreateDirectory(uploadsFolder);
- }
+ EnsureDirectoryExists(uploadsFolder);
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
- await File.WriteAllBytesAsync(filePath, data);
+ using (var fileStream = new FileStream(filePath, FileMode.Create))
+ {
+ await data.CopyToAsync(fileStream);
+ }
- // Return relative path for web access if needed, but entity expects FilePath
- // Let's return the relative path from wwwroot
return Path.Combine("uploads", uniqueFileName);
}
public async Task SaveCoverAsync(byte[] data, string fileName)
{
if (data == null || data.Length == 0) return null;
+ using var stream = new MemoryStream(data);
+ return await SaveCoverAsync(stream, fileName);
+ }
+ public async Task SaveCoverAsync(Stream data, string fileName)
+ {
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
- if (!Directory.Exists(coversFolder))
- {
- Directory.CreateDirectory(coversFolder);
- }
+ EnsureDirectoryExists(coversFolder);
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var filePath = Path.Combine(coversFolder, uniqueFileName);
- await File.WriteAllBytesAsync(filePath, data);
+ using (var fileStream = new FileStream(filePath, FileMode.Create))
+ {
+ await data.CopyToAsync(fileStream);
+ }
return Path.Combine("covers", uniqueFileName);
}
+
+ private void EnsureDirectoryExists(string path)
+ {
+ if (!Directory.Exists(path))
+ {
+ Directory.CreateDirectory(path);
+ }
+ }
}
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor
index b9d87c0..6caa2b0 100644
--- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor
+++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor
@@ -216,8 +216,7 @@
if (result != null)
{
await CloseModal();
- // Navigate to the newly added book
- // ReaderNavigation.NavigateToBook(result.Id);
+ ReaderNavigation.NavigateToBook(result.Id);
}
}
else
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
index e8689a2..64f412f 100644
--- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
+++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
@@ -291,7 +291,7 @@
position: absolute;
width: 150%;
height: 150%;
- background: radial-gradient(circle, rgba(0, 255, 153, 0.15) 0%, transparent 70%);
+ background: radial-gradient(circle, var(--nexus-neon-alpha, rgba(0, 255, 153, 0.15)) 0%, transparent 70%);
animation: pulseGlow 4s infinite alternate;
}
@@ -299,7 +299,7 @@
color: var(--nexus-neon, #00ffaa);
opacity: 0.5;
z-index: 1;
- filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.3));
+ filter: drop-shadow(0 0 10px var(--nexus-neon-alpha-deep, rgba(0, 255, 153, 0.3)));
}
.verification-form {
@@ -341,13 +341,13 @@
.error-message {
margin-top: 1rem;
- color: #ff5555;
+ color: var(--nexus-error, #ff5555);
text-align: center;
font-size: 0.9rem;
padding: 0.75rem;
- background: rgba(255, 85, 85, 0.1);
+ background: var(--nexus-error-alpha, rgba(255, 85, 85, 0.1));
border-radius: 8px;
- border: 1px solid rgba(255, 85, 85, 0.2);
+ border: 1px solid var(--nexus-error-alpha-deep, rgba(255, 85, 85, 0.2));
}
@keyframes pulseGlow {
diff --git a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs
index 31c1b25..ff7096d 100644
--- a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs
+++ b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs
@@ -12,4 +12,9 @@ public interface IReaderNavigationService
Task GoToNextChapter();
Task GoToPreviousChapter();
Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title);
+
+ ///
+ /// Navigates to the reader for a specific book.
+ ///
+ void NavigateToBook(Guid bookId);
}
diff --git a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs
index 837daf7..15e55ce 100644
--- a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs
+++ b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs
@@ -1,9 +1,17 @@
using System.Linq;
+using Microsoft.AspNetCore.Components;
namespace NexusReader.UI.Shared.Services;
public class ReaderNavigationService : IReaderNavigationService
{
+ private readonly NavigationManager _navigationManager;
+
+ public ReaderNavigationService(NavigationManager navigationManager)
+ {
+ _navigationManager = navigationManager;
+ }
+
public int CurrentChapterIndex { get; private set; } = 0;
public int TotalChapters { get; private set; } = 1;
public string ChapterTitle { get; private set; } = "Loading...";
@@ -47,6 +55,11 @@ public class ReaderNavigationService : IReaderNavigationService
}
}
+ public void NavigateToBook(Guid bookId)
+ {
+ _navigationManager.NavigateTo($"/reader/{bookId}");
+ }
+
private async Task NotifyNavigationChangedAsync()
{
var handlers = OnNavigationChanged?.GetInvocationList();
diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs
index cb7cd55..eeb4f74 100644
--- a/src/NexusReader.Web.Client/Program.cs
+++ b/src/NexusReader.Web.Client/Program.cs
@@ -68,6 +68,10 @@ public class ThrowingEmbeddingGenerator : IEmbeddingGenerator SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException();
- public Task SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException();
+ private const string ErrorMessage = "File storage operations are not supported in the WASM client. Use the API endpoint for ingestion.";
+
+ public Task SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
+ public Task SaveEbookAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
+ public Task SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
+ public Task SaveCoverAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
}
diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs
index 0ed60e3..0cdd861 100644
--- a/src/NexusReader.Web/Program.cs
+++ b/src/NexusReader.Web/Program.cs
@@ -67,7 +67,6 @@ builder.Services.AddHttpClient("NexusAPI", (sp, client) =>
});
builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI"));
-builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
builder.Services.AddCascadingAuthenticationState();