@using Microsoft.AspNetCore.Components.Forms @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Queries.Reader @using NexusReader.Application.Commands.Library @using NexusReader.UI.Shared.Services @using System.Net.Http.Json @inject IEpubMetadataExtractor MetadataExtractor @inject ILogger Logger @inject HttpClient Http @inject IReaderNavigationService ReaderNavigation @inject IJSRuntime JSRuntime @inject ISyncService SyncService @implements IAsyncDisposable @if (IsOpen) { } @code { /// /// Gets or sets a value indicating whether the modal is open. /// [Parameter] public bool IsOpen { get; set; } /// /// Event triggered when the IsOpen state changes. /// [Parameter] public EventCallback IsOpenChanged { get; set; } private bool _isDragging; private bool IsParsing { get; set; } private bool IsVerifying { get; set; } private bool IsIngesting { get; set; } private bool IsIndexing { get; set; } private string IngestionStatusMessage { get; set; } = "Initializing..."; private double IngestionProgressPercent { get; set; } private Guid IngestedBookId { get; set; } = Guid.Empty; private LocalEpubMetadata? Metadata { get; set; } private string? ErrorMessage { get; set; } private byte[]? _epubBytes; private bool _disposed; private bool IsUploadActive => !IsParsing && !IsVerifying && !IsIngesting && !IsIndexing; // Allow up to 50 MB private const long MaxFileSize = 50 * 1024 * 1024; protected override async Task OnInitializedAsync() { await SyncService.InitializeAsync(); SyncService.OnIngestionProgressReceived += HandleIngestionProgress; } private async Task HandleIngestionProgress(string message, double progress) { if (_disposed) return; if (!IsIndexing) return; IngestionStatusMessage = message; IngestionProgressPercent = progress; if (!_disposed) { // Dispatch the state change to the Blazor synchronization context // because this event is triggered asynchronously from a SignalR / WebSocket background thread. await InvokeAsync(StateHasChanged); } if (progress >= 1.0) { // Give the user a moment to see the completion message await Task.Delay(2500); if (_disposed) return; // Now close the modal and navigate to the book if (IngestedBookId != Guid.Empty) { var bookId = IngestedBookId; // Dispatch UI updates and navigation back to the Blazor thread // to avoid thread affinity issues and potential UI lockups in MAUI/Web applications. await InvokeAsync(async () => { if (_disposed) return; await CloseModal(); ReaderNavigation.NavigateToBook(bookId); }); } } } private async Task CloseModal() { if (IsIngesting || IsIndexing) return; IsOpen = false; Reset(); await IsOpenChanged.InvokeAsync(false); } private void Reset() { IsParsing = false; IsVerifying = false; IsIngesting = false; IsIndexing = false; IngestionStatusMessage = "Initializing..."; IngestionProgressPercent = 0.0; IngestedBookId = Guid.Empty; Metadata = null; ErrorMessage = null; _isDragging = false; _epubBytes = null; } 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); using var memoryStream = new MemoryStream(); await stream.CopyToAsync(memoryStream); if (_disposed) return; _epubBytes = memoryStream.ToArray(); memoryStream.Position = 0; var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream); if (_disposed) return; if (result.IsSuccess) { Metadata = result.Value; IsVerifying = true; } else { ErrorMessage = result.Errors.FirstOrDefault()?.Message ?? "Failed to parse EPUB."; } } catch (Exception ex) { Logger.LogError(ex, "Error uploading EPUB"); if (!_disposed) { ErrorMessage = $"An unexpected error occurred: {ex.Message}"; } } finally { if (!_disposed) { IsParsing = false; StateHasChanged(); } } } private async Task SaveToLibrary() { if (Metadata == null || _epubBytes == null) return; IsIngesting = true; ErrorMessage = null; StateHasChanged(); try { var request = new IngestEbookRequest( Metadata.Title, Metadata.Author, Metadata.CoverImage != null ? Convert.ToBase64String(Metadata.CoverImage) : null, Convert.ToBase64String(_epubBytes), Metadata.Description ); var response = await Http.PostAsJsonAsync("api/library/ingest", request); if (_disposed) return; if (response.IsSuccessStatusCode) { var result = await response.Content.ReadFromJsonAsync(); if (_disposed) return; if (result != null) { IngestedBookId = result.Id; IsVerifying = false; IsIngesting = false; IsIndexing = true; IngestionStatusMessage = "Book saved! Starting background indexing..."; IngestionProgressPercent = 0.0; StateHasChanged(); } } else { ErrorMessage = await response.Content.ReadAsStringAsync(); IsIngesting = false; } } catch (Exception ex) { Logger.LogError(ex, "Error during ingestion"); if (!_disposed) { ErrorMessage = "Failed to save book to library. Please try again."; IsIngesting = false; } } finally { if (!_disposed) { StateHasChanged(); } } } private record IngestResult(Guid Id); public async ValueTask DisposeAsync() { _disposed = true; SyncService.OnIngestionProgressReceived -= HandleIngestionProgress; // Clear the large byte array so it is eligible for GC even if the component is cached. _epubBytes = null; await ValueTask.CompletedTask; } }