feat(ingestion): implement hybrid metadata verification form #34 (#41)

### Description
This PR implements **Issue #34: [UI/UX] Implement Hybrid Metadata Verification Form in Ingestion Modal**.

### Key Changes
- **Metadata Verification State**: Introduced a new state in `BookIngestionModal.razor` allowing users to edit `Title` and `Author` before final ingestion.
- **Cover Image Preview**: Added a high-fidelity cover preview with a CSS-based glowing placeholder fallback for books without embedded covers.
- **Ingestion Pipeline**:
  - Implemented `IngestEbookCommand` and `IngestEbookCommandHandler`.
  - Added `IBookStorageService` and its implementation for managing EPUB and cover file storage.
  - Exposed `POST /api/library/ingest` Minimal API endpoint with `.DisableAntiforgery()` to handle client-side JSON uploads.
- **Stability Fixes**:
  - Resolved DI validation errors in the WASM client by providing a dummy `IBookStorageService` registration.
  - Adjusted Kestrel request limits to handle large EPUB payloads (up to 100MB).
  - Corrected middleware ordering to ensure Antiforgery works correctly with Authentication.

### Verification
- Solution builds successfully.
- Manual verification of modal state transitions and API ingestion logic.

Closes #34.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #41
Reviewed-by: Marek Jaisński <jasins.marek@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #41.
This commit is contained in:
2026-05-12 18:19:07 +00:00
committed by Marek Jaisński
parent fe5ff81c98
commit d5c2952bec
15 changed files with 533 additions and 24 deletions
+40 -4
View File
@@ -1,9 +1,11 @@
using NexusReader.Web.Components;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Components;
using NexusReader.Application;
using NexusReader.Infrastructure;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.User;
using NexusReader.Application.Commands.Library;
using MediatR;
using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services;
@@ -48,13 +50,23 @@ builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddHttpClient("NexusAPI", client =>
builder.Services.AddHttpClient("NexusAPI", (sp, client) =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000");
var configuration = sp.GetRequiredService<IConfiguration>();
var apiBaseUrl = configuration["ApiBaseUrl"];
if (!string.IsNullOrEmpty(apiBaseUrl))
{
client.BaseAddress = new Uri(apiBaseUrl);
}
else
{
// For local development/Interactive Server, we use the current base address
var nav = sp.GetRequiredService<NavigationManager>();
client.BaseAddress = new Uri(nav.BaseUri);
}
});
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>();
builder.Services.AddCascadingAuthenticationState();
@@ -220,9 +232,9 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
}
app.UseAntiforgery();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
@@ -281,6 +293,30 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
return Results.BadRequest(errorMsg);
});
app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var epubData = Convert.FromBase64String(request.EpubDataBase64);
byte[]? coverData = !string.IsNullOrEmpty(request.CoverImageBase64)
? Convert.FromBase64String(request.CoverImageBase64)
: null;
var command = new IngestEbookCommand(
request.Title,
request.AuthorName,
coverData,
epubData,
userId
);
var result = await mediator.Send(command);
if (result.IsSuccess) return Results.Ok(new { Id = result.Value });
return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Ingestion failed");
}).RequireAuthorization().DisableAntiforgery();
app.MapPost("/api/StripeWebhook", async (
HttpContext context,
UserManager<NexusUser> userManager,