From 5acde17a53d55a1d0ea06190b7e4cb83a5f7e605 Mon Sep 17 00:00:00 2001 From: janis Date: Sat, 28 Mar 2026 15:17:20 +0200 Subject: [PATCH] Changed design language. Added editions, better support for authors. Base for file handling --- .../Helpers/BookFactory.cs | 8 + .../Integration/BooksControllerTests.cs | 4 +- .../Integration/FilesControllerTests.cs | 189 ++++ .../Unit/Services/BooksServiceTests.cs | 41 +- .../Unit/Services/FileScannerServiceTests.cs | 90 ++ .../PageManager.Api/Api/Dtos/AuthorDto.cs | 26 + .../PageManager.Api/Api/Dtos/BookDto.cs | 17 + .../PageManager.Api/Api/Dtos/BookFileDto.cs | 21 + .../PageManager.Api/Api/Dtos/HardcoverDtos.cs | 30 +- .../Controllers/AuthorsController.cs | 21 + .../Controllers/BooksController.cs | 7 + .../Controllers/FilesController.cs | 71 ++ .../PageManager.Api/Data/AppDbContext.cs | 40 +- .../PageManager.Api/Data/Models/Author.cs | 5 + .../PageManager.Api/Data/Models/Book.cs | 2 + .../PageManager.Api/Data/Models/BookAuthor.cs | 4 +- .../PageManager.Api/Data/Models/BookFile.cs | 27 + .../PageManager.Api/Data/Models/Edition.cs | 36 + .../PageManager.Api/Data/Models/FileFormat.cs | 12 + .../Data/Models/ReadingFormat.cs | 12 + .../Data/Repositories/AuthorsRepository.cs | 29 + .../Data/Repositories/BooksRepository.cs | 93 +- .../Data/Repositories/FilesRepository.cs | 53 + .../Data/Repositories/IAuthorsRepository.cs | 9 + .../Data/Repositories/IBooksRepository.cs | 4 +- .../Data/Repositories/IFilesRepository.cs | 14 + .../20260328000000_AddEditions.Designer.cs | 375 +++++++ .../Migrations/20260328000000_AddEditions.cs | 50 + .../20260328000001_AddEditionFields.cs | 85 ++ .../20260328000002_DropReadingFormatId.cs | 31 + .../20260328000003_AddAuthorFields.cs | 55 + .../20260329000000_AddBookFiles.Designer.cs | 532 ++++++++++ .../Migrations/20260329000000_AddBookFiles.cs | 81 ++ .../Migrations/AppDbContextModelSnapshot.cs | 211 +++- .../PageManager.Api/PageManager.Api.csproj | 6 + PageManager.Api/PageManager.Api/Program.cs | 6 + .../Services/AuthorsService.cs | 40 + .../PageManager.Api/Services/BooksService.cs | 83 +- .../Services/FileScannerBackgroundService.cs | 34 + .../Services/FileScannerService.cs | 144 +++ .../Services/HardcoverService.cs | 168 ++- .../Services/IAuthorsService.cs | 9 + .../PageManager.Api/Services/IBooksService.cs | 6 + .../Services/IFileScannerService.cs | 6 + .../PageManager.Api/Services/IFileSystem.cs | 9 + .../Services/PhysicalFileSystem.cs | 21 + .../PageManager.Api/appsettings.json | 3 +- PageManager.Web/index.html | 3 +- PageManager.Web/package-lock.json | 958 +++++++++++++++++- PageManager.Web/package.json | 8 +- PageManager.Web/src/App.module.css | 21 +- PageManager.Web/src/App.tsx | 46 +- PageManager.Web/src/api/authors.ts | 10 + PageManager.Web/src/api/books.ts | 4 + PageManager.Web/src/api/files.ts | 25 + .../AddBookDialog/AddBookDialog.module.css | 195 +--- .../AddBookDialog/AddBookDialog.tsx | 146 +-- .../components/BookCard/BookCard.module.css | 72 +- .../src/components/BookCard/BookCard.tsx | 15 +- .../src/components/BookRow/BookRow.module.css | 71 ++ .../src/components/BookRow/BookRow.tsx | 67 ++ .../DetailPanel/DetailPanel.module.css | 260 +---- .../components/DetailPanel/DetailPanel.tsx | 255 +++-- .../MetadataForm/MetadataForm.module.css | 225 +--- .../components/MetadataForm/MetadataForm.tsx | 198 ++-- .../components/QueueItem/QueueItem.module.css | 105 +- .../src/components/QueueItem/QueueItem.tsx | 74 +- .../src/components/Sidebar/Sidebar.tsx | 131 ++- PageManager.Web/src/index.css | 90 +- .../AuthorDetail/AuthorDetail.module.css | 72 ++ .../src/pages/AuthorDetail/AuthorDetail.tsx | 160 +++ .../src/pages/Authors/Authors.module.css | 63 ++ PageManager.Web/src/pages/Authors/Authors.tsx | 95 ++ .../pages/BookDetail/BookDetail.module.css | 81 ++ .../src/pages/BookDetail/BookDetail.tsx | 329 ++++++ .../src/pages/Import/Import.module.css | 214 +--- PageManager.Web/src/pages/Import/Import.tsx | 230 +++-- .../src/pages/Library/Library.module.css | 166 +-- PageManager.Web/src/pages/Library/Library.tsx | 212 ++-- .../src/pages/Metadata/Metadata.module.css | 167 ++- .../src/pages/Metadata/Metadata.tsx | 153 ++- PageManager.Web/src/types/index.ts | 61 +- PageManager.Web/vitest.setup.ts | 22 + compose.yaml | 20 + 84 files changed, 5861 insertions(+), 1983 deletions(-) create mode 100644 PageManager.Api/PageManager.Api.Tests/Integration/FilesControllerTests.cs create mode 100644 PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs create mode 100644 PageManager.Api/PageManager.Api/Api/Dtos/BookFileDto.cs create mode 100644 PageManager.Api/PageManager.Api/Controllers/AuthorsController.cs create mode 100644 PageManager.Api/PageManager.Api/Controllers/FilesController.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Models/BookFile.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Models/Edition.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Models/FileFormat.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Models/ReadingFormat.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Repositories/AuthorsRepository.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Repositories/FilesRepository.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Repositories/IAuthorsRepository.cs create mode 100644 PageManager.Api/PageManager.Api/Data/Repositories/IFilesRepository.cs create mode 100644 PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.Designer.cs create mode 100644 PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.cs create mode 100644 PageManager.Api/PageManager.Api/Migrations/20260328000001_AddEditionFields.cs create mode 100644 PageManager.Api/PageManager.Api/Migrations/20260328000002_DropReadingFormatId.cs create mode 100644 PageManager.Api/PageManager.Api/Migrations/20260328000003_AddAuthorFields.cs create mode 100644 PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.Designer.cs create mode 100644 PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.cs create mode 100644 PageManager.Api/PageManager.Api/Services/AuthorsService.cs create mode 100644 PageManager.Api/PageManager.Api/Services/FileScannerBackgroundService.cs create mode 100644 PageManager.Api/PageManager.Api/Services/FileScannerService.cs create mode 100644 PageManager.Api/PageManager.Api/Services/IAuthorsService.cs create mode 100644 PageManager.Api/PageManager.Api/Services/IFileScannerService.cs create mode 100644 PageManager.Api/PageManager.Api/Services/IFileSystem.cs create mode 100644 PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs create mode 100644 PageManager.Web/src/api/authors.ts create mode 100644 PageManager.Web/src/api/files.ts create mode 100644 PageManager.Web/src/components/BookRow/BookRow.module.css create mode 100644 PageManager.Web/src/components/BookRow/BookRow.tsx create mode 100644 PageManager.Web/src/pages/AuthorDetail/AuthorDetail.module.css create mode 100644 PageManager.Web/src/pages/AuthorDetail/AuthorDetail.tsx create mode 100644 PageManager.Web/src/pages/Authors/Authors.module.css create mode 100644 PageManager.Web/src/pages/Authors/Authors.tsx create mode 100644 PageManager.Web/src/pages/BookDetail/BookDetail.module.css create mode 100644 PageManager.Web/src/pages/BookDetail/BookDetail.tsx diff --git a/PageManager.Api/PageManager.Api.Tests/Helpers/BookFactory.cs b/PageManager.Api/PageManager.Api.Tests/Helpers/BookFactory.cs index 16d8de2..aec0a6b 100644 --- a/PageManager.Api/PageManager.Api.Tests/Helpers/BookFactory.cs +++ b/PageManager.Api/PageManager.Api.Tests/Helpers/BookFactory.cs @@ -34,6 +34,7 @@ public static class BookFactory HardcoverId = hardcoverId, BookAuthors = [], SeriesEntries = [], + Editions = [], }; } @@ -53,6 +54,13 @@ public static class BookFactory return book; } + public static Book WithEditions(this Book book, params (string? Isbn, string? Publisher, string? CoverUrl)[] editions) + { + foreach (var (isbn, publisher, coverUrl) in editions) + book.Editions.Add(new Edition { BookId = book.Id, Isbn = isbn, Publisher = publisher, CoverUrl = coverUrl }); + return book; + } + public static Book WithSeries(this Book book, int seriesId = 1, string seriesName = "Test Series", double position = 1.0, string? arc = null) { var series = new Series { Id = seriesId, Name = seriesName }; diff --git a/PageManager.Api/PageManager.Api.Tests/Integration/BooksControllerTests.cs b/PageManager.Api/PageManager.Api.Tests/Integration/BooksControllerTests.cs index 8e53683..2ef0b26 100644 --- a/PageManager.Api/PageManager.Api.Tests/Integration/BooksControllerTests.cs +++ b/PageManager.Api/PageManager.Api.Tests/Integration/BooksControllerTests.cs @@ -30,7 +30,7 @@ public class BooksControllerTests : IAsyncLifetime using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.ExecuteSqlRawAsync( - "TRUNCATE book_authors, series_entries, books, authors, series RESTART IDENTITY CASCADE"); + "TRUNCATE book_files, book_authors, series_entries, editions, books, authors, series RESTART IDENTITY CASCADE"); } public Task DisposeAsync() @@ -166,7 +166,7 @@ public class BooksControllerTests : IAsyncLifetime Year = 1965, Publisher = "Ace Books", Pages = 412, - Authors = ["Frank Herbert"], + Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }], Genres = ["Science Fiction"], Isbn = "9780441013593", CoverColor = "#c4a35a", diff --git a/PageManager.Api/PageManager.Api.Tests/Integration/FilesControllerTests.cs b/PageManager.Api/PageManager.Api.Tests/Integration/FilesControllerTests.cs new file mode 100644 index 0000000..d882aa2 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Integration/FilesControllerTests.cs @@ -0,0 +1,189 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; +using PageManager.Api.Tests.Integration.Fixtures; + +namespace PageManager.Api.Tests.Integration; + +[Collection("Postgres")] +public class FilesControllerTests : IAsyncLifetime +{ + private readonly TestWebAppFactory _factory; + private readonly HttpClient _client; + + public FilesControllerTests(PostgresFixture postgres) + { + _factory = new TestWebAppFactory(postgres); + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.ExecuteSqlRawAsync( + "TRUNCATE book_files, book_authors, series_entries, editions, books, authors, series, import_sources RESTART IDENTITY CASCADE"); + } + + public Task DisposeAsync() + { + _client.Dispose(); + _factory.Dispose(); + return Task.CompletedTask; + } + + // ── GET /api/books/{id}/files ───────────────────────────────────────────── + + [Fact] + public async Task GetBookFiles_NoFiles_Returns200WithEmptyArray() + { + var bookId = await SeedBookAsync(); + + var response = await _client.GetAsync($"/api/books/{bookId}/files"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var files = await response.Content.ReadFromJsonAsync(); + files.Should().BeEmpty(); + } + + [Fact] + public async Task GetBookFiles_WithFiles_Returns200WithCorrectData() + { + var bookId = await SeedBookAsync("Dune"); + await SeedFileAsync(filename: "dune.epub", format: FileFormat.Epub, bookId: bookId); + await SeedFileAsync(filename: "dune.mobi", format: FileFormat.Mobi, bookId: bookId); + + var response = await _client.GetAsync($"/api/books/{bookId}/files"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var files = await response.Content.ReadFromJsonAsync(); + files.Should().HaveCount(2); + files!.Select(f => f.Filename).Should().BeEquivalentTo(["dune.epub", "dune.mobi"]); + files.First(f => f.Filename == "dune.epub").Format.Should().Be("epub"); + } + + // ── GET /api/files?unmatched=true ───────────────────────────────────────── + + [Fact] + public async Task GetUnmatchedFiles_MixedFiles_ReturnsOnlyUnmatched() + { + var bookId = await SeedBookAsync(); + await SeedFileAsync(filename: "unmatched.epub", bookId: null); + await SeedFileAsync(filename: "matched.epub", bookId: bookId); + + var response = await _client.GetAsync("/api/files?unmatched=true"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var files = await response.Content.ReadFromJsonAsync(); + files.Should().ContainSingle(f => f.Filename == "unmatched.epub"); + files.Should().NotContain(f => f.Filename == "matched.epub"); + } + + [Fact] + public async Task GetFiles_NoUnmatchedParam_Returns400() + { + var response = await _client.GetAsync("/api/files"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ── PATCH /api/files/{id} ───────────────────────────────────────────────── + + [Fact] + public async Task AssignFile_Exists_Returns200WithUpdatedBookId() + { + var bookId = await SeedBookAsync(); + var fileId = await SeedFileAsync(filename: "orphan.epub", bookId: null); + + var response = await _client.PatchAsJsonAsync($"/api/files/{fileId}", new { bookId }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var file = await response.Content.ReadFromJsonAsync(); + file!.BookId.Should().Be(bookId); + } + + [Fact] + public async Task AssignFile_NotFound_Returns404() + { + var response = await _client.PatchAsJsonAsync("/api/files/99999", new { bookId = 1 }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task AssignFile_ClearsBookId_WhenSetToNull() + { + var bookId = await SeedBookAsync(); + var fileId = await SeedFileAsync(filename: "book.epub", bookId: bookId); + + var response = await _client.PatchAsJsonAsync($"/api/files/{fileId}", new { bookId = (int?)null }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var file = await response.Content.ReadFromJsonAsync(); + file!.BookId.Should().BeNull(); + } + + // ── DELETE /api/files/{id} ──────────────────────────────────────────────── + + [Fact] + public async Task DeleteFile_Exists_Returns204AndRemovesRecord() + { + var fileId = await SeedFileAsync(); + + var response = await _client.DeleteAsync($"/api/files/{fileId}"); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var exists = await db.BookFiles.AnyAsync(f => f.Id == fileId); + exists.Should().BeFalse(); + } + + [Fact] + public async Task DeleteFile_NotFound_Returns404() + { + var response = await _client.DeleteAsync("/api/files/99999"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task SeedBookAsync(string title = "Test Book") + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var book = new Book { Title = title, Formats = [], Genres = [] }; + db.Books.Add(book); + await db.SaveChangesAsync(); + return book.Id; + } + + private async Task SeedFileAsync( + string filename = "test.epub", + FileFormat format = FileFormat.Epub, + int? bookId = null, + long sizeBytes = 1024) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var file = new BookFile + { + Filename = filename, + Path = filename, + Format = format, + SizeBytes = sizeBytes, + BookId = bookId, + AddedAt = DateTime.UtcNow, + }; + db.BookFiles.Add(file); + await db.SaveChangesAsync(); + return file.Id; + } +} diff --git a/PageManager.Api/PageManager.Api.Tests/Unit/Services/BooksServiceTests.cs b/PageManager.Api/PageManager.Api.Tests/Unit/Services/BooksServiceTests.cs index 0cedcb6..e311e24 100644 --- a/PageManager.Api/PageManager.Api.Tests/Unit/Services/BooksServiceTests.cs +++ b/PageManager.Api/PageManager.Api.Tests/Unit/Services/BooksServiceTests.cs @@ -175,6 +175,39 @@ public class BooksServiceTests result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]); } + // ── Editions DTO mapping ────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_BookWithEditions_MapsEditionsToDto() + { + var book = BookFactory.Create(id: 10) + .WithAuthors((1, "Author")) + .WithEditions( + ("9780441013593", "Ace Books", "https://example.com/cover1.jpg"), + (null, "Bantam", null)); + _repo.GetByIdAsync(10).Returns(book); + + var result = await _sut.GetByIdAsync(10); + + result!.Editions.Should().HaveCount(2); + result.Editions[0].Isbn.Should().Be("9780441013593"); + result.Editions[0].Publisher.Should().Be("Ace Books"); + result.Editions[0].CoverUrl.Should().Be("https://example.com/cover1.jpg"); + result.Editions[1].Isbn.Should().BeNull(); + result.Editions[1].Publisher.Should().Be("Bantam"); + } + + [Fact] + public async Task GetByIdAsync_BookWithNoEditions_ReturnsEmptyEditionsArray() + { + var book = BookFactory.Create(id: 11).WithAuthors((1, "Author")); + _repo.GetByIdAsync(11).Returns(book); + + var result = await _sut.GetByIdAsync(11); + + result!.Editions.Should().BeEmpty(); + } + // ── CreateFromHardcoverAsync ────────────────────────────────────────────── [Fact] @@ -202,7 +235,7 @@ public class BooksServiceTests result.Should().BeNull(); await _repo.DidNotReceive().CreateBookAsync( Arg.Any(), - Arg.Any>(), + Arg.Any>(), Arg.Any<(string, double, string?)?> ()); } @@ -219,7 +252,7 @@ public class BooksServiceTests Publisher = "Ace Books", Pages = 412, Description = "A sci-fi classic.", - Authors = ["Frank Herbert"], + Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }], Genres = ["Science Fiction"], Isbn = "9780441013593", CoverUrl = "https://example.com/cover.jpg", @@ -231,7 +264,7 @@ public class BooksServiceTests var createdBook = BookFactory.Create(id: 55, title: "Dune") .WithAuthors((1, "Frank Herbert")) .WithSeries(seriesName: "Dune Chronicles", position: 1.0); - _repo.CreateBookAsync(Arg.Any(), Arg.Any>(), Arg.Any<(string, double, string?)?>()) + _repo.CreateBookAsync(Arg.Any(), Arg.Any>(), Arg.Any<(string, double, string?)?>()) .Returns(createdBook); var result = await _sut.CreateFromHardcoverAsync(123); @@ -242,7 +275,7 @@ public class BooksServiceTests result.Series!.Name.Should().Be("Dune Chronicles"); await _repo.Received(1).CreateBookAsync( Arg.Is(b => b.HardcoverId == 123 && b.Title == "Dune"), - Arg.Is>(a => a.Contains("Frank Herbert")), + Arg.Is>(a => a.Any(ha => ha.Name == "Frank Herbert")), Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles")); } } diff --git a/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs b/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs new file mode 100644 index 0000000..596dd04 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; +using PageManager.Api.Data.Models; +using PageManager.Api.Services; +using PageManager.Api.Tests.Helpers; + +namespace PageManager.Api.Tests.Unit.Services; + +public class FileScannerServiceTests +{ + // ── GetFormatFromExtension ──────────────────────────────────────────────── + + [Theory] + [InlineData(".epub", FileFormat.Epub)] + [InlineData(".mobi", FileFormat.Mobi)] + [InlineData(".pdf", FileFormat.Pdf)] + [InlineData(".m4b", FileFormat.M4b)] + [InlineData(".mp3", FileFormat.Mp3)] + [InlineData(".aac", FileFormat.Aac)] + [InlineData(".flac", FileFormat.Flac)] + [InlineData(".EPUB", FileFormat.Epub)] // case-insensitive + [InlineData(".PDF", FileFormat.Pdf)] + public void GetFormatFromExtension_KnownExtension_ReturnsFormat(string ext, FileFormat expected) + { + FileScannerService.GetFormatFromExtension(ext).Should().Be(expected); + } + + [Theory] + [InlineData(".txt")] + [InlineData(".docx")] + [InlineData(".zip")] + [InlineData("")] + public void GetFormatFromExtension_UnknownExtension_ReturnsNull(string ext) + { + FileScannerService.GetFormatFromExtension(ext).Should().BeNull(); + } + + // ── FindMatch ───────────────────────────────────────────────────────────── + + [Fact] + public void FindMatch_FilenameContainsBookTitle_ReturnsBook() + { + var books = new[] { BookFactory.Create(id: 1, title: "Dune") }; + var file = new BookFile { Filename = "Dune - Frank Herbert.epub" }; + + FileScannerService.FindMatch(file, books)!.Id.Should().Be(1); + } + + [Fact] + public void FindMatch_CaseInsensitive_ReturnsBook() + { + var books = new[] { BookFactory.Create(id: 2, title: "The Way of Kings") }; + var file = new BookFile { Filename = "the way of kings.epub" }; + + FileScannerService.FindMatch(file, books)!.Id.Should().Be(2); + } + + [Fact] + public void FindMatch_FilenameContainsIsbn_ReturnsBook() + { + var books = new[] { BookFactory.Create(id: 3, title: "Foundation", isbn: "9780553293357") }; + var file = new BookFile { Filename = "9780553293357.epub" }; + + FileScannerService.FindMatch(file, books)!.Id.Should().Be(3); + } + + [Fact] + public void FindMatch_NoMatch_ReturnsNull() + { + var books = new[] { BookFactory.Create(id: 1, title: "Dune") }; + var file = new BookFile { Filename = "Foundation - Isaac Asimov.epub" }; + + FileScannerService.FindMatch(file, books).Should().BeNull(); + } + + [Fact] + public void FindMatch_EmptyBookList_ReturnsNull() + { + FileScannerService.FindMatch(new BookFile { Filename = "anything.epub" }, []).Should().BeNull(); + } + + [Fact] + public void FindMatch_BookWithNullIsbn_DoesNotThrow() + { + var books = new[] { BookFactory.Create(id: 1, title: "Dune", isbn: null) }; + var file = new BookFile { Filename = "some-file.epub" }; + + var act = () => FileScannerService.FindMatch(file, books); + act.Should().NotThrow(); + } +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/AuthorDto.cs b/PageManager.Api/PageManager.Api/Api/Dtos/AuthorDto.cs index a16faaa..579065e 100644 --- a/PageManager.Api/PageManager.Api/Api/Dtos/AuthorDto.cs +++ b/PageManager.Api/PageManager.Api/Api/Dtos/AuthorDto.cs @@ -4,4 +4,30 @@ public class AuthorDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; + public string? Bio { get; set; } + public int? BornYear { get; set; } + public string? ImageUrl { get; set; } + public string? Slug { get; set; } + public string Role { get; set; } = "Author"; +} + +public class AuthorSummaryDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Bio { get; set; } + public int? BornYear { get; set; } + public string? ImageUrl { get; set; } + public int BookCount { get; set; } +} + +public class AuthorDetailDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Bio { get; set; } + public int? BornYear { get; set; } + public string? ImageUrl { get; set; } + public string? Slug { get; set; } + public BookDto[] Books { get; set; } = []; } diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs b/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs index c21f038..d979205 100644 --- a/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs +++ b/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs @@ -1,5 +1,21 @@ namespace PageManager.Api.Api.Dtos; +public class EditionDto +{ + public int Id { get; set; } + public string? Isbn { get; set; } + public string? Asin { get; set; } + public string? Publisher { get; set; } + public int? ReleaseYear { get; set; } + public PageManager.Api.Data.Models.ReadingFormat? ReadingFormat { get; set; } + public string? EditionFormat { get; set; } + public int? Pages { get; set; } + public int? AudioSeconds { get; set; } + public string? Language { get; set; } + public string? LanguageCode { get; set; } + public string? CoverUrl { get; set; } +} + public class BookSeriesDto { public string Name { get; set; } = string.Empty; @@ -23,4 +39,5 @@ public class BookDto public string? CoverUrl { get; set; } public string? Isbn { get; set; } public int? HardcoverId { get; set; } + public EditionDto[] Editions { get; set; } = []; } diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/BookFileDto.cs b/PageManager.Api/PageManager.Api/Api/Dtos/BookFileDto.cs new file mode 100644 index 0000000..296f3f1 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/BookFileDto.cs @@ -0,0 +1,21 @@ +namespace PageManager.Api.Api.Dtos; + +public class BookFileDto +{ + public int Id { get; set; } + public int? BookId { get; set; } + public int? EditionId { get; set; } + public string? SourceId { get; set; } + public string Path { get; set; } = string.Empty; + public string Filename { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public string Format { get; set; } = string.Empty; + public string? Hash { get; set; } + public DateTime AddedAt { get; set; } +} + +public class AssignFileRequest +{ + public int? BookId { get; set; } + public int? EditionId { get; set; } +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/HardcoverDtos.cs b/PageManager.Api/PageManager.Api/Api/Dtos/HardcoverDtos.cs index 600dd21..d0503ed 100644 --- a/PageManager.Api/PageManager.Api/Api/Dtos/HardcoverDtos.cs +++ b/PageManager.Api/PageManager.Api/Api/Dtos/HardcoverDtos.cs @@ -9,6 +9,17 @@ public class HardcoverBookResult public string[] Genres { get; set; } = []; } +public class HardcoverAuthor +{ + public int? HardcoverId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Bio { get; set; } + public int? BornYear { get; set; } + public string? ImageUrl { get; set; } + public string? Slug { get; set; } + public string Role { get; set; } = "Author"; +} + public class HardcoverBookDetails { public int Id { get; set; } @@ -16,13 +27,14 @@ public class HardcoverBookDetails public string? Description { get; set; } public int? Pages { get; set; } public int? Year { get; set; } - public string[] Authors { get; set; } = []; + public HardcoverAuthor[] Authors { get; set; } = []; public string[] Genres { get; set; } = []; public string? Isbn { get; set; } public string? Publisher { get; set; } public string? CoverUrl { get; set; } public string? CoverColor { get; set; } public HardcoverSeriesInfo? Series { get; set; } + public HardcoverEdition[] Editions { get; set; } = []; } public class HardcoverSeriesInfo @@ -31,6 +43,22 @@ public class HardcoverSeriesInfo public double Position { get; set; } } +public class HardcoverEdition +{ + public string? Isbn { get; set; } + public string? Asin { get; set; } + public string? Publisher { get; set; } + public int? ReleaseYear { get; set; } + public PageManager.Api.Data.Models.ReadingFormat? ReadingFormat { get; set; } + public string? EditionFormat { get; set; } + public int? Pages { get; set; } + public int? AudioSeconds { get; set; } + public string? Language { get; set; } + public string? LanguageCode { get; set; } + public string? CoverUrl { get; set; } + public string? CoverColor { get; set; } +} + public class CreateBookFromHardcoverRequest { public int HardcoverId { get; set; } diff --git a/PageManager.Api/PageManager.Api/Controllers/AuthorsController.cs b/PageManager.Api/PageManager.Api/Controllers/AuthorsController.cs new file mode 100644 index 0000000..3eeabe9 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/AuthorsController.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Services; + +namespace PageManager.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthorsController(IAuthorsService authors) : ControllerBase +{ + [HttpGet] + public async Task> GetAuthors() => + await authors.GetAllAsync(); + + [HttpGet("{id}")] + public async Task> GetAuthor(int id) + { + var author = await authors.GetByIdAsync(id); + return author is null ? NotFound() : Ok(author); + } +} diff --git a/PageManager.Api/PageManager.Api/Controllers/BooksController.cs b/PageManager.Api/PageManager.Api/Controllers/BooksController.cs index 001096c..825c9f9 100644 --- a/PageManager.Api/PageManager.Api/Controllers/BooksController.cs +++ b/PageManager.Api/PageManager.Api/Controllers/BooksController.cs @@ -32,4 +32,11 @@ public class BooksController(IBooksService books) : ControllerBase var book = await books.UpdateAsync(id, req); return book is null ? NotFound() : Ok(book); } + + [HttpPost("{id}/fetch-metadata")] + public async Task> FetchMetadata(int id) + { + var book = await books.RefreshFromHardcoverAsync(id); + return book is null ? NotFound() : Ok(book); + } } diff --git a/PageManager.Api/PageManager.Api/Controllers/FilesController.cs b/PageManager.Api/PageManager.Api/Controllers/FilesController.cs new file mode 100644 index 0000000..38a3a5f --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/FilesController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data.Models; +using PageManager.Api.Data.Repositories; +using PageManager.Api.Services; + +namespace PageManager.Api.Controllers; + +[ApiController] +[Route("api")] +public class FilesController(IFilesRepository filesRepo, IFileScannerService scanner) : ControllerBase +{ + // GET /api/books/{id}/files + [HttpGet("books/{id:int}/files")] + public async Task GetByBook(int id) + { + var files = await filesRepo.GetByBookIdAsync(id); + return Ok(files.Select(ToDto)); + } + + // GET /api/files?unmatched=true + [HttpGet("files")] + public async Task GetFiles([FromQuery] bool unmatched = false) + { + if (!unmatched) + return BadRequest("Specify ?unmatched=true to list unmatched files."); + + var files = await filesRepo.GetUnmatchedAsync(); + return Ok(files.Select(ToDto)); + } + + // PATCH /api/files/{id} + [HttpPatch("files/{id:int}")] + public async Task Assign(int id, [FromBody] AssignFileRequest req) + { + var file = await filesRepo.AssignAsync(id, req.BookId, req.EditionId); + if (file is null) return NotFound(); + return Ok(ToDto(file)); + } + + // DELETE /api/files/{id} + [HttpDelete("files/{id:int}")] + public async Task Delete(int id) + { + var deleted = await filesRepo.DeleteAsync(id); + if (!deleted) return NotFound(); + return NoContent(); + } + + // POST /api/scan + [HttpPost("scan")] + public async Task TriggerScan(CancellationToken ct) + { + await scanner.ScanAsync(ct); + return Ok(new { message = "Scan complete." }); + } + + private static BookFileDto ToDto(BookFile f) => new() + { + Id = f.Id, + BookId = f.BookId, + EditionId = f.EditionId, + SourceId = f.SourceId, + Path = f.Path, + Filename = f.Filename, + SizeBytes = f.SizeBytes, + Format = f.Format.ToString().ToLowerInvariant(), + Hash = f.Hash, + AddedAt = f.AddedAt, + }; +} diff --git a/PageManager.Api/PageManager.Api/Data/AppDbContext.cs b/PageManager.Api/PageManager.Api/Data/AppDbContext.cs index 7404655..1b9b721 100644 --- a/PageManager.Api/PageManager.Api/Data/AppDbContext.cs +++ b/PageManager.Api/PageManager.Api/Data/AppDbContext.cs @@ -10,8 +10,10 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Series => Set(); public DbSet BookAuthors => Set(); public DbSet SeriesEntries => Set(); + public DbSet Editions => Set(); public DbSet ImportSources => Set(); public DbSet ImportQueueItems => Set(); + public DbSet BookFiles => Set(); protected override void OnModelCreating(ModelBuilder model) { @@ -28,8 +30,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op .WithMany(a => a.BookAuthors) .HasForeignKey(ba => ba.AuthorId); - e.Property(ba => ba.Role) - .HasConversion(); + // Role is stored as plain text — no conversion needed }); // ── SeriesEntry (composite PK) ─────────────────────────────────────── @@ -66,6 +67,17 @@ public class AppDbContext(DbContextOptions options) : DbContext(op e.HasIndex(b => b.Isbn); }); + // ── Edition ────────────────────────────────────────────────────────── + model.Entity(e => + { + e.HasOne(ed => ed.Book) + .WithMany(b => b.Editions) + .HasForeignKey(ed => ed.BookId); + + e.Property(ed => ed.ReadingFormat) + .HasConversion(); + }); + // ── Author ─────────────────────────────────────────────────────────── model.Entity(e => { @@ -85,5 +97,29 @@ public class AppDbContext(DbContextOptions options) : DbContext(op e.Property(i => i.Id).ValueGeneratedNever(); e.Property(i => i.Status).HasConversion(); }); + + // ── BookFile ────────────────────────────────────────────────────────── + model.Entity(e => + { + e.HasOne(f => f.Book) + .WithMany(b => b.BookFiles) + .HasForeignKey(f => f.BookId) + .OnDelete(DeleteBehavior.SetNull); + + e.HasOne(f => f.Edition) + .WithMany(ed => ed.BookFiles) + .HasForeignKey(f => f.EditionId) + .OnDelete(DeleteBehavior.SetNull); + + e.HasOne(f => f.Source) + .WithMany() + .HasForeignKey(f => f.SourceId) + .OnDelete(DeleteBehavior.SetNull); + + e.Property(f => f.Format).HasConversion(); + e.HasIndex(f => f.Hash); + e.HasIndex(f => f.BookId); + e.HasIndex(f => f.EditionId); + }); } } diff --git a/PageManager.Api/PageManager.Api/Data/Models/Author.cs b/PageManager.Api/PageManager.Api/Data/Models/Author.cs index 93b9b0d..a16072b 100644 --- a/PageManager.Api/PageManager.Api/Data/Models/Author.cs +++ b/PageManager.Api/PageManager.Api/Data/Models/Author.cs @@ -3,7 +3,12 @@ namespace PageManager.Api.Data.Models; public class Author { public int Id { get; set; } + public int? HardcoverId { get; set; } public string Name { get; set; } = string.Empty; + public string? Bio { get; set; } + public int? BornYear { get; set; } + public string? ImageUrl { get; set; } + public string? Slug { get; set; } public ICollection BookAuthors { get; set; } = []; } diff --git a/PageManager.Api/PageManager.Api/Data/Models/Book.cs b/PageManager.Api/PageManager.Api/Data/Models/Book.cs index ee24efe..d31a422 100644 --- a/PageManager.Api/PageManager.Api/Data/Models/Book.cs +++ b/PageManager.Api/PageManager.Api/Data/Models/Book.cs @@ -27,4 +27,6 @@ public class Book public ICollection BookAuthors { get; set; } = []; public ICollection SeriesEntries { get; set; } = []; + public ICollection Editions { get; set; } = []; + public ICollection BookFiles { get; set; } = []; } diff --git a/PageManager.Api/PageManager.Api/Data/Models/BookAuthor.cs b/PageManager.Api/PageManager.Api/Data/Models/BookAuthor.cs index d43f2c4..3ce35dd 100644 --- a/PageManager.Api/PageManager.Api/Data/Models/BookAuthor.cs +++ b/PageManager.Api/PageManager.Api/Data/Models/BookAuthor.cs @@ -1,7 +1,5 @@ namespace PageManager.Api.Data.Models; -public enum AuthorRole { Author, Editor } - public class BookAuthor { public int BookId { get; set; } @@ -10,5 +8,5 @@ public class BookAuthor public int AuthorId { get; set; } public Author Author { get; set; } = null!; - public AuthorRole Role { get; set; } = AuthorRole.Author; + public string Role { get; set; } = "Author"; } diff --git a/PageManager.Api/PageManager.Api/Data/Models/BookFile.cs b/PageManager.Api/PageManager.Api/Data/Models/BookFile.cs new file mode 100644 index 0000000..249d291 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/BookFile.cs @@ -0,0 +1,27 @@ +namespace PageManager.Api.Data.Models; + +public class BookFile +{ + public int Id { get; set; } + + public int? BookId { get; set; } + public Book? Book { get; set; } + + public int? EditionId { get; set; } + public Edition? Edition { get; set; } + + public string? SourceId { get; set; } + public ImportSource? Source { get; set; } + + /// Path relative to the source root directory. + public string Path { get; set; } = string.Empty; + + public string Filename { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public FileFormat Format { get; set; } + + /// SHA-256 hex digest — used for deduplication. + public string? Hash { get; set; } + + public DateTime AddedAt { get; set; } = DateTime.UtcNow; +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/Edition.cs b/PageManager.Api/PageManager.Api/Data/Models/Edition.cs new file mode 100644 index 0000000..624f37e --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/Edition.cs @@ -0,0 +1,36 @@ +namespace PageManager.Api.Data.Models; + +public class Edition +{ + public int Id { get; set; } + public int BookId { get; set; } + public Book Book { get; set; } = null!; + + // Identification + public string? Isbn { get; set; } + public string? Asin { get; set; } + + // Publishing + public string? Publisher { get; set; } + public int? ReleaseYear { get; set; } + + // Format + public ReadingFormat? ReadingFormat { get; set; } + /// Detailed edition format (e.g. "Hardcover", "Mass Market Paperback"). + public string? EditionFormat { get; set; } + + // Content + public int? Pages { get; set; } + /// Audiobook duration in seconds. + public int? AudioSeconds { get; set; } + + // Language + public string? Language { get; set; } + public string? LanguageCode { get; set; } + + // Cover + public string? CoverUrl { get; set; } + public string? CoverColor { get; set; } + + public ICollection BookFiles { get; set; } = []; +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/FileFormat.cs b/PageManager.Api/PageManager.Api/Data/Models/FileFormat.cs new file mode 100644 index 0000000..ce3ead4 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/FileFormat.cs @@ -0,0 +1,12 @@ +namespace PageManager.Api.Data.Models; + +public enum FileFormat +{ + Epub, + Mobi, + Pdf, + M4b, + Mp3, + Aac, + Flac, +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/ReadingFormat.cs b/PageManager.Api/PageManager.Api/Data/Models/ReadingFormat.cs new file mode 100644 index 0000000..d84a739 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/ReadingFormat.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace PageManager.Api.Data.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReadingFormat +{ + Physical = 1, + Audio = 2, + Both = 3, + Ebook = 4, +} diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/AuthorsRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/AuthorsRepository.cs new file mode 100644 index 0000000..9103253 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Repositories/AuthorsRepository.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Data.Repositories; + +public class AuthorsRepository(AppDbContext db) : IAuthorsRepository +{ + public Task> GetAllAsync() => + db.Authors + .Include(a => a.BookAuthors) + .OrderBy(a => a.Name) + .ToListAsync() + .ContinueWith(t => (IEnumerable)t.Result); + + public Task GetByIdAsync(int id) => + db.Authors + .Include(a => a.BookAuthors) + .ThenInclude(ba => ba.Book) + .ThenInclude(b => b.BookAuthors) + .ThenInclude(ba => ba.Author) + .Include(a => a.BookAuthors) + .ThenInclude(ba => ba.Book) + .ThenInclude(b => b.SeriesEntries) + .ThenInclude(se => se.Series) + .Include(a => a.BookAuthors) + .ThenInclude(ba => ba.Book) + .ThenInclude(b => b.Editions) + .FirstOrDefaultAsync(a => a.Id == id); +} diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs index f4d333b..9a4756a 100644 --- a/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs +++ b/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using PageManager.Api.Api.Dtos; using PageManager.Api.Data.Models; namespace PageManager.Api.Data.Repositories; @@ -9,6 +10,7 @@ public class BooksRepository(AppDbContext db) : IBooksRepository db.Books .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .Include(b => b.Editions) .ToListAsync() .ContinueWith(t => (IEnumerable)t.Result); @@ -16,34 +18,28 @@ public class BooksRepository(AppDbContext db) : IBooksRepository db.Books .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .Include(b => b.Editions) .FirstOrDefaultAsync(b => b.Id == id); public Task FindByHardcoverIdAsync(int hardcoverId) => db.Books .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .Include(b => b.Editions) .FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId); public async Task CreateBookAsync( Book book, - IReadOnlyList authorNames, + IReadOnlyList authors, (string name, double position, string? arc)? series) { db.Books.Add(book); await db.SaveChangesAsync(); - // Resolve / create authors - var authors = new List(); - foreach (var name in authorNames) - { - var author = await db.Authors.FirstOrDefaultAsync(a => a.Name == name) - ?? new Author { Name = name }; - if (author.Id == 0) db.Authors.Add(author); - authors.Add(author); - } - if (authors.Any(a => a.Id == 0)) await db.SaveChangesAsync(); - foreach (var author in authors) - db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id }); + // Resolve / create authors and link to book + var resolved = await ResolveAuthorsAsync(authors); + foreach (var (author, role) in resolved) + db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id, Role = role }); // Resolve / create series if (series is { } si) @@ -66,8 +62,79 @@ public class BooksRepository(AppDbContext db) : IBooksRepository return await db.Books .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .Include(b => b.Editions) + .FirstAsync(b => b.Id == book.Id); + } + + public async Task SyncHardcoverDataAsync(Book book, IReadOnlyList authors, IReadOnlyList editions) + { + // Scalar fields are already mutated on the tracked entity — persist them + await db.SaveChangesAsync(); + + // Replace authors + var oldAuthors = await db.BookAuthors.Where(ba => ba.BookId == book.Id).ToListAsync(); + db.BookAuthors.RemoveRange(oldAuthors); + + var resolved = await ResolveAuthorsAsync(authors); + foreach (var (author, role) in resolved) + db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id, Role = role }); + + // Replace editions + var oldEditions = await db.Editions.Where(e => e.BookId == book.Id).ToListAsync(); + db.Editions.RemoveRange(oldEditions); + foreach (var edition in editions) + { + edition.BookId = book.Id; + db.Editions.Add(edition); + } + + await db.SaveChangesAsync(); + + return await db.Books + .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) + .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .Include(b => b.Editions) .FirstAsync(b => b.Id == book.Id); } public Task SaveAsync() => db.SaveChangesAsync(); + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task> ResolveAuthorsAsync(IReadOnlyList hardcoverAuthors) + { + var result = new List<(Author, string)>(); + + foreach (var ha in hardcoverAuthors) + { + Author? author = null; + + // Look up by Hardcover ID first (most reliable), then fall back to name + if (ha.HardcoverId.HasValue) + author = await db.Authors.FirstOrDefaultAsync(a => a.HardcoverId == ha.HardcoverId); + author ??= await db.Authors.FirstOrDefaultAsync(a => a.Name == ha.Name); + + if (author is null) + { + author = new Author(); + db.Authors.Add(author); + } + + // Always update with latest Hardcover data + author.HardcoverId = ha.HardcoverId; + author.Name = ha.Name; + author.Bio = ha.Bio; + author.BornYear = ha.BornYear; + author.ImageUrl = ha.ImageUrl; + author.Slug = ha.Slug; + + result.Add((author, ha.Role)); + } + + // Flush new Author rows so they get IDs + if (result.Any(r => r.Item1.Id == 0)) + await db.SaveChangesAsync(); + + return result; + } } diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/FilesRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/FilesRepository.cs new file mode 100644 index 0000000..18ec643 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Repositories/FilesRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Data.Repositories; + +public class FilesRepository(AppDbContext db) : IFilesRepository +{ + public Task> GetByBookIdAsync(int bookId) => + db.BookFiles + .Where(f => f.BookId == bookId) + .OrderBy(f => f.Filename) + .ToListAsync() + .ContinueWith(t => (IEnumerable)t.Result); + + public Task> GetUnmatchedAsync() => + db.BookFiles + .Where(f => f.BookId == null) + .OrderBy(f => f.Filename) + .ToListAsync() + .ContinueWith(t => (IEnumerable)t.Result); + + public Task GetByIdAsync(int id) => + db.BookFiles.FirstOrDefaultAsync(f => f.Id == id); + + public Task FindByHashAsync(string hash) => + db.BookFiles.FirstOrDefaultAsync(f => f.Hash == hash); + + public async Task AddAsync(BookFile file) + { + db.BookFiles.Add(file); + await db.SaveChangesAsync(); + return file; + } + + public async Task AssignAsync(int id, int? bookId, int? editionId) + { + var file = await db.BookFiles.FindAsync(id); + if (file is null) return null; + file.BookId = bookId; + file.EditionId = editionId; + await db.SaveChangesAsync(); + return file; + } + + public async Task DeleteAsync(int id) + { + var file = await db.BookFiles.FindAsync(id); + if (file is null) return false; + db.BookFiles.Remove(file); + await db.SaveChangesAsync(); + return true; + } +} diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/IAuthorsRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/IAuthorsRepository.cs new file mode 100644 index 0000000..d567b7b --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Repositories/IAuthorsRepository.cs @@ -0,0 +1,9 @@ +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Data.Repositories; + +public interface IAuthorsRepository +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); +} diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/IBooksRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/IBooksRepository.cs index 920abb1..0a5ecf5 100644 --- a/PageManager.Api/PageManager.Api/Data/Repositories/IBooksRepository.cs +++ b/PageManager.Api/PageManager.Api/Data/Repositories/IBooksRepository.cs @@ -1,3 +1,4 @@ +using PageManager.Api.Api.Dtos; using PageManager.Api.Data.Models; namespace PageManager.Api.Data.Repositories; @@ -9,7 +10,8 @@ public interface IBooksRepository Task FindByHardcoverIdAsync(int hardcoverId); Task CreateBookAsync( Book book, - IReadOnlyList authorNames, + IReadOnlyList authors, (string name, double position, string? arc)? series); + Task SyncHardcoverDataAsync(Book book, IReadOnlyList authors, IReadOnlyList editions); Task SaveAsync(); } diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/IFilesRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/IFilesRepository.cs new file mode 100644 index 0000000..25201bf --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Repositories/IFilesRepository.cs @@ -0,0 +1,14 @@ +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Data.Repositories; + +public interface IFilesRepository +{ + Task> GetByBookIdAsync(int bookId); + Task> GetUnmatchedAsync(); + Task GetByIdAsync(int id); + Task FindByHashAsync(string hash); + Task AddAsync(BookFile file); + Task AssignAsync(int id, int? bookId, int? editionId); + Task DeleteAsync(int id); +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.Designer.cs b/PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.Designer.cs new file mode 100644 index 0000000..6eb43be --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.Designer.cs @@ -0,0 +1,375 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260328000000_AddEditions")] + partial class AddEditions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasColumnType("text") + .HasColumnName("color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.PrimitiveCollection("Formats") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("formats"); + + b.PrimitiveCollection("Genres") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("genres"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_books"); + + b.HasIndex("HardcoverId") + .HasDatabaseName("ix_books_hardcover_id"); + + b.HasIndex("Isbn") + .HasDatabaseName("ix_books_isbn"); + + b.ToTable("books", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("AuthorId") + .HasColumnType("integer") + .HasColumnName("author_id"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("BookId", "AuthorId") + .HasName("pk_book_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_book_authors_author_id"); + + b.ToTable("book_authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("CoverColor") + .HasColumnType("text") + .HasColumnName("cover_color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.HasKey("Id") + .HasName("pk_editions"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_editions_book_id"); + + b.ToTable("editions", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint") + .HasColumnName("downloaded_bytes"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text") + .HasColumnName("filename"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_import_queue_items"); + + b.ToTable("import_queue_items", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportSource", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_import_sources"); + + b.ToTable("import_sources", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_series"); + + b.ToTable("series", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.Property("SeriesId") + .HasColumnType("integer") + .HasColumnName("series_id"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Arc") + .HasColumnType("text") + .HasColumnName("arc"); + + b.Property("Position") + .HasColumnType("double precision") + .HasColumnName("position"); + + b.HasKey("SeriesId", "BookId") + .HasName("pk_series_entries"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_series_entries_book_id"); + + b.ToTable("series_entries", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.HasOne("PageManager.Api.Data.Models.Author", "Author") + .WithMany("BookAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_authors_author_id"); + + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookAuthors") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_books_book_id"); + + b.Navigation("Author"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("Editions") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_editions_books_book_id"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("SeriesEntries") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Series", "Series") + .WithMany("Entries") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_series_series_id"); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Navigation("BookAuthors"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Navigation("BookAuthors"); + + b.Navigation("Editions"); + + b.Navigation("SeriesEntries"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.cs b/PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.cs new file mode 100644 index 0000000..f22f54f --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260328000000_AddEditions.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + /// + public partial class AddEditions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "editions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + book_id = table.Column(type: "integer", nullable: false), + isbn = table.Column(type: "text", nullable: true), + publisher = table.Column(type: "text", nullable: true), + cover_url = table.Column(type: "text", nullable: true), + cover_color = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_editions", x => x.id); + table.ForeignKey( + name: "fk_editions_books_book_id", + column: x => x.book_id, + principalTable: "books", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_editions_book_id", + table: "editions", + column: "book_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "editions"); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260328000001_AddEditionFields.cs b/PageManager.Api/PageManager.Api/Migrations/20260328000001_AddEditionFields.cs new file mode 100644 index 0000000..69fa230 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260328000001_AddEditionFields.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260328000001_AddEditionFields")] + public partial class AddEditionFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "asin", + table: "editions", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "audio_seconds", + table: "editions", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "edition_format", + table: "editions", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "language", + table: "editions", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "language_code", + table: "editions", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "pages", + table: "editions", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "reading_format", + table: "editions", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "reading_format_id", + table: "editions", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "release_year", + table: "editions", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "asin", table: "editions"); + migrationBuilder.DropColumn(name: "audio_seconds", table: "editions"); + migrationBuilder.DropColumn(name: "edition_format", table: "editions"); + migrationBuilder.DropColumn(name: "language", table: "editions"); + migrationBuilder.DropColumn(name: "language_code", table: "editions"); + migrationBuilder.DropColumn(name: "pages", table: "editions"); + migrationBuilder.DropColumn(name: "reading_format", table: "editions"); + migrationBuilder.DropColumn(name: "reading_format_id", table: "editions"); + migrationBuilder.DropColumn(name: "release_year", table: "editions"); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260328000002_DropReadingFormatId.cs b/PageManager.Api/PageManager.Api/Migrations/20260328000002_DropReadingFormatId.cs new file mode 100644 index 0000000..a95412d --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260328000002_DropReadingFormatId.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260328000002_DropReadingFormatId")] + public partial class DropReadingFormatId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "reading_format_id", + table: "editions"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "reading_format_id", + table: "editions", + type: "integer", + nullable: true); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260328000003_AddAuthorFields.cs b/PageManager.Api/PageManager.Api/Migrations/20260328000003_AddAuthorFields.cs new file mode 100644 index 0000000..495ab90 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260328000003_AddAuthorFields.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260328000003_AddAuthorFields")] + public partial class AddAuthorFields : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "hardcover_id", + table: "authors", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "bio", + table: "authors", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "born_year", + table: "authors", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "image_url", + table: "authors", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "slug", + table: "authors", + type: "text", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "hardcover_id", table: "authors"); + migrationBuilder.DropColumn(name: "bio", table: "authors"); + migrationBuilder.DropColumn(name: "born_year", table: "authors"); + migrationBuilder.DropColumn(name: "image_url", table: "authors"); + migrationBuilder.DropColumn(name: "slug", table: "authors"); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.Designer.cs b/PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.Designer.cs new file mode 100644 index 0000000..7197d03 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.Designer.cs @@ -0,0 +1,532 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260329000000_AddBookFiles")] + partial class AddBookFiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("BornYear") + .HasColumnType("integer") + .HasColumnName("born_year"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("ImageUrl") + .HasColumnType("text") + .HasColumnName("image_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .HasColumnType("text") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasColumnType("text") + .HasColumnName("color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.PrimitiveCollection("Formats") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("formats"); + + b.PrimitiveCollection("Genres") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("genres"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_books"); + + b.HasIndex("HardcoverId") + .HasDatabaseName("ix_books_hardcover_id"); + + b.HasIndex("Isbn") + .HasDatabaseName("ix_books_isbn"); + + b.ToTable("books", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("AuthorId") + .HasColumnType("integer") + .HasColumnName("author_id"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("BookId", "AuthorId") + .HasName("pk_book_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_book_authors_author_id"); + + b.ToTable("book_authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("EditionId") + .HasColumnType("integer") + .HasColumnName("edition_id"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text") + .HasColumnName("filename"); + + b.Property("Format") + .IsRequired() + .HasColumnType("text") + .HasColumnName("format") + .HasConversion(); + + b.Property("Hash") + .HasColumnType("text") + .HasColumnName("hash"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("SourceId") + .HasColumnType("text") + .HasColumnName("source_id"); + + b.HasKey("Id") + .HasName("pk_book_files"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_book_files_book_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_files_edition_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_book_files_hash"); + + b.HasIndex("SourceId") + .HasDatabaseName("ix_book_files_source_id"); + + b.ToTable("book_files", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Asin") + .HasColumnType("text") + .HasColumnName("asin"); + + b.Property("AudioSeconds") + .HasColumnType("integer") + .HasColumnName("audio_seconds"); + + b.Property("CoverColor") + .HasColumnType("text") + .HasColumnName("cover_color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("EditionFormat") + .HasColumnType("text") + .HasColumnName("edition_format"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Language") + .HasColumnType("text") + .HasColumnName("language"); + + b.Property("LanguageCode") + .HasColumnType("text") + .HasColumnName("language_code"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("ReadingFormat") + .HasColumnType("text") + .HasColumnName("reading_format") + .HasConversion(); + + b.Property("ReleaseYear") + .HasColumnType("integer") + .HasColumnName("release_year"); + + b.HasKey("Id") + .HasName("pk_editions"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_editions_book_id"); + + b.ToTable("editions", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint") + .HasColumnName("downloaded_bytes"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text") + .HasColumnName("filename"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_import_queue_items"); + + b.ToTable("import_queue_items", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportSource", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_import_sources"); + + b.ToTable("import_sources", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_series"); + + b.ToTable("series", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.Property("SeriesId") + .HasColumnType("integer") + .HasColumnName("series_id"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Arc") + .HasColumnType("text") + .HasColumnName("arc"); + + b.Property("Position") + .HasColumnType("double precision") + .HasColumnName("position"); + + b.HasKey("SeriesId", "BookId") + .HasName("pk_series_entries"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_series_entries_book_id"); + + b.ToTable("series_entries", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.HasOne("PageManager.Api.Data.Models.Author", "Author") + .WithMany("BookAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_authors_author_id"); + + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookAuthors") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_books_book_id"); + + b.Navigation("Author"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookFiles") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_book_files_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Edition", "Edition") + .WithMany("BookFiles") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_book_files_editions_edition_id"); + + b.HasOne("PageManager.Api.Data.Models.ImportSource", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_book_files_import_sources_source_id"); + + b.Navigation("Book"); + + b.Navigation("Edition"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("Editions") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_editions_books_book_id"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("SeriesEntries") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Series", "Series") + .WithMany("Entries") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_series_series_id"); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Navigation("BookAuthors"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Navigation("BookAuthors"); + + b.Navigation("BookFiles"); + + b.Navigation("Editions"); + + b.Navigation("SeriesEntries"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.Navigation("BookFiles"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.cs b/PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.cs new file mode 100644 index 0000000..4df396e --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260329000000_AddBookFiles.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + /// + public partial class AddBookFiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "book_files", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + book_id = table.Column(type: "integer", nullable: true), + edition_id = table.Column(type: "integer", nullable: true), + source_id = table.Column(type: "text", nullable: true), + path = table.Column(type: "text", nullable: false), + filename = table.Column(type: "text", nullable: false), + size_bytes = table.Column(type: "bigint", nullable: false), + format = table.Column(type: "text", nullable: false), + hash = table.Column(type: "text", nullable: true), + added_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("pk_book_files", x => x.id); + table.ForeignKey( + name: "fk_book_files_books_book_id", + column: x => x.book_id, + principalTable: "books", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_book_files_editions_edition_id", + column: x => x.edition_id, + principalTable: "editions", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_book_files_import_sources_source_id", + column: x => x.source_id, + principalTable: "import_sources", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "ix_book_files_book_id", + table: "book_files", + column: "book_id"); + + migrationBuilder.CreateIndex( + name: "ix_book_files_edition_id", + table: "book_files", + column: "edition_id"); + + migrationBuilder.CreateIndex( + name: "ix_book_files_hash", + table: "book_files", + column: "hash"); + + migrationBuilder.CreateIndex( + name: "ix_book_files_source_id", + table: "book_files", + column: "source_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "book_files"); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs b/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs index 26161c0..a0480ec 100644 --- a/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ -// +// +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -30,11 +31,31 @@ namespace PageManager.Api.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("BornYear") + .HasColumnType("integer") + .HasColumnName("born_year"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("ImageUrl") + .HasColumnType("text") + .HasColumnName("image_url"); + b.Property("Name") .IsRequired() .HasColumnType("text") .HasColumnName("name"); + b.Property("Slug") + .HasColumnType("text") + .HasColumnName("slug"); + b.HasKey("Id") .HasName("pk_authors"); @@ -134,6 +155,144 @@ namespace PageManager.Api.Migrations b.ToTable("book_authors", (string)null); }); + modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("EditionId") + .HasColumnType("integer") + .HasColumnName("edition_id"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text") + .HasColumnName("filename"); + + b.Property("Format") + .IsRequired() + .HasColumnType("text") + .HasColumnName("format") + .HasConversion(); + + b.Property("Hash") + .HasColumnType("text") + .HasColumnName("hash"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("SourceId") + .HasColumnType("text") + .HasColumnName("source_id"); + + b.HasKey("Id") + .HasName("pk_book_files"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_book_files_book_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_files_edition_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_book_files_hash"); + + b.HasIndex("SourceId") + .HasDatabaseName("ix_book_files_source_id"); + + b.ToTable("book_files", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Asin") + .HasColumnType("text") + .HasColumnName("asin"); + + b.Property("AudioSeconds") + .HasColumnType("integer") + .HasColumnName("audio_seconds"); + + b.Property("CoverColor") + .HasColumnType("text") + .HasColumnName("cover_color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("EditionFormat") + .HasColumnType("text") + .HasColumnName("edition_format"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Language") + .HasColumnType("text") + .HasColumnName("language"); + + b.Property("LanguageCode") + .HasColumnType("text") + .HasColumnName("language_code"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("ReadingFormat") + .HasColumnType("text") + .HasColumnName("reading_format") + .HasConversion(); + + b.Property("ReleaseYear") + .HasColumnType("integer") + .HasColumnName("release_year"); + + b.HasKey("Id") + .HasName("pk_editions"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_editions_book_id"); + + b.ToTable("editions", (string)null); + }); + modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b => { b.Property("Id") @@ -277,6 +436,45 @@ namespace PageManager.Api.Migrations b.Navigation("Book"); }); + modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookFiles") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_book_files_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Edition", "Edition") + .WithMany("BookFiles") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_book_files_editions_edition_id"); + + b.HasOne("PageManager.Api.Data.Models.ImportSource", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_book_files_import_sources_source_id"); + + b.Navigation("Book"); + + b.Navigation("Edition"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("Editions") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_editions_books_book_id"); + + b.Navigation("Book"); + }); + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => { b.HasOne("PageManager.Api.Data.Models.Book", "Book") @@ -307,9 +505,20 @@ namespace PageManager.Api.Migrations { b.Navigation("BookAuthors"); + b.Navigation("BookFiles"); + + b.Navigation("Editions"); + b.Navigation("SeriesEntries"); }); + modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b => + { + b.Navigation("BookFiles"); + + b.Navigation("Book"); + }); + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => { b.Navigation("Entries"); diff --git a/PageManager.Api/PageManager.Api/PageManager.Api.csproj b/PageManager.Api/PageManager.Api/PageManager.Api.csproj index a0b221a..b4cdcd1 100644 --- a/PageManager.Api/PageManager.Api/PageManager.Api.csproj +++ b/PageManager.Api/PageManager.Api/PageManager.Api.csproj @@ -7,6 +7,12 @@ Linux + + + <_Parameter1>PageManager.Api.Tests + + + diff --git a/PageManager.Api/PageManager.Api/Program.cs b/PageManager.Api/PageManager.Api/Program.cs index f5584aa..e66ece9 100644 --- a/PageManager.Api/PageManager.Api/Program.cs +++ b/PageManager.Api/PageManager.Api/Program.cs @@ -33,8 +33,14 @@ builder.Host.UseSerilog((ctx, services, cfg) => cfg builder.Services.AddControllers(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddOpenApi(); builder.Services.AddDbContext(options => diff --git a/PageManager.Api/PageManager.Api/Services/AuthorsService.cs b/PageManager.Api/PageManager.Api/Services/AuthorsService.cs new file mode 100644 index 0000000..32cac45 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/AuthorsService.cs @@ -0,0 +1,40 @@ +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data.Repositories; + +namespace PageManager.Api.Services; + +public class AuthorsService(IAuthorsRepository repo) : IAuthorsService +{ + public async Task> GetAllAsync() + { + var authors = await repo.GetAllAsync(); + return authors.Select(a => new AuthorSummaryDto + { + Id = a.Id, + Name = a.Name, + Bio = a.Bio, + BornYear = a.BornYear, + ImageUrl = a.ImageUrl, + BookCount = a.BookAuthors.Count, + }); + } + + public async Task GetByIdAsync(int id) + { + var author = await repo.GetByIdAsync(id); + if (author is null) return null; + + return new AuthorDetailDto + { + Id = author.Id, + Name = author.Name, + Bio = author.Bio, + BornYear = author.BornYear, + ImageUrl = author.ImageUrl, + Slug = author.Slug, + Books = author.BookAuthors + .Select(ba => BooksService.ToDto(ba.Book)) + .ToArray(), + }; + } +} diff --git a/PageManager.Api/PageManager.Api/Services/BooksService.cs b/PageManager.Api/PageManager.Api/Services/BooksService.cs index ff17ae8..6ec4603 100644 --- a/PageManager.Api/PageManager.Api/Services/BooksService.cs +++ b/PageManager.Api/PageManager.Api/Services/BooksService.cs @@ -63,6 +63,21 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) : CoverUrl = details.CoverUrl, Isbn = details.Isbn, HardcoverId = details.Id, + Editions = details.Editions.Select(e => new Edition + { + Isbn = e.Isbn, + Asin = e.Asin, + Publisher = e.Publisher, + ReleaseYear = e.ReleaseYear, + ReadingFormat = e.ReadingFormat, + EditionFormat = e.EditionFormat, + Pages = e.Pages, + AudioSeconds = e.AudioSeconds, + Language = e.Language, + LanguageCode = e.LanguageCode, + CoverUrl = e.CoverUrl, + CoverColor = e.CoverColor, + }).ToList(), }; (string name, double position, string? arc)? seriesArg = details.Series is { } s @@ -72,7 +87,47 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) : return ToDto(created); } - private static BookDto ToDto(Book book) + public async Task RefreshFromHardcoverAsync(int id) + { + var book = await repo.GetByIdAsync(id); + if (book is null || book.HardcoverId is null) return null; + + var details = await hardcover.GetBookDetailsAsync(book.HardcoverId.Value); + if (details is null) return null; + + var color = details.CoverColor is { Length: > 0 } c && c.StartsWith('#') ? c : book.Color; + + book.Title = details.Title; + book.Year = details.Year; + book.Publisher = details.Publisher; + book.Pages = details.Pages; + book.Description = details.Description; + book.Color = color; + book.Genres = details.Genres; + book.CoverUrl = details.CoverUrl; + book.Isbn = details.Isbn; + + var editions = details.Editions.Select(e => new Edition + { + Isbn = e.Isbn, + Asin = e.Asin, + Publisher = e.Publisher, + ReleaseYear = e.ReleaseYear, + ReadingFormat = e.ReadingFormat, + EditionFormat = e.EditionFormat, + Pages = e.Pages, + AudioSeconds = e.AudioSeconds, + Language = e.Language, + LanguageCode = e.LanguageCode, + CoverUrl = e.CoverUrl, + CoverColor = e.CoverColor, + }).ToList(); + + var updated = await repo.SyncHardcoverDataAsync(book, details.Authors, editions); + return ToDto(updated); + } + + internal static BookDto ToDto(Book book) { var entry = book.SeriesEntries.FirstOrDefault(); return new BookDto @@ -87,7 +142,16 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) : Color = book.Color, Genres = book.Genres, Authors = book.BookAuthors - .Select(ba => new AuthorDto { Id = ba.Author.Id, Name = ba.Author.Name }) + .Select(ba => new AuthorDto + { + Id = ba.Author.Id, + Name = ba.Author.Name, + Bio = ba.Author.Bio, + BornYear = ba.Author.BornYear, + ImageUrl = ba.Author.ImageUrl, + Slug = ba.Author.Slug, + Role = ba.Role, + }) .ToArray(), Series = entry is null ? null : new BookSeriesDto { @@ -98,6 +162,21 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) : CoverUrl = book.CoverUrl, Isbn = book.Isbn, HardcoverId = book.HardcoverId, + Editions = book.Editions.Select(e => new EditionDto + { + Id = e.Id, + Isbn = e.Isbn, + Asin = e.Asin, + Publisher = e.Publisher, + ReleaseYear = e.ReleaseYear, + ReadingFormat = e.ReadingFormat, + EditionFormat = e.EditionFormat, + Pages = e.Pages, + AudioSeconds = e.AudioSeconds, + Language = e.Language, + LanguageCode = e.LanguageCode, + CoverUrl = e.CoverUrl, + }).ToArray(), }; } } diff --git a/PageManager.Api/PageManager.Api/Services/FileScannerBackgroundService.cs b/PageManager.Api/PageManager.Api/Services/FileScannerBackgroundService.cs new file mode 100644 index 0000000..6fcd566 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/FileScannerBackgroundService.cs @@ -0,0 +1,34 @@ +namespace PageManager.Api.Services; + +public class FileScannerBackgroundService( + IServiceScopeFactory scopeFactory, + IHostApplicationLifetime lifetime, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait until the app is fully started before scanning + var ready = new TaskCompletionSource(); + lifetime.ApplicationStarted.Register(() => ready.TrySetResult()); + await ready.Task.WaitAsync(stoppingToken); + + if (stoppingToken.IsCancellationRequested) return; + + logger.LogInformation("Starting initial library scan"); + try + { + using var scope = scopeFactory.CreateScope(); + var scanner = scope.ServiceProvider.GetRequiredService(); + await scanner.ScanAsync(stoppingToken); + logger.LogInformation("Initial library scan complete"); + } + catch (OperationCanceledException) + { + // Normal shutdown + } + catch (Exception ex) + { + logger.LogError(ex, "Library scan failed"); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Services/FileScannerService.cs b/PageManager.Api/PageManager.Api/Services/FileScannerService.cs new file mode 100644 index 0000000..76f6b9c --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/FileScannerService.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; +using PageManager.Api.Data.Repositories; + +namespace PageManager.Api.Services; + +public class FileScannerService( + AppDbContext db, + IFilesRepository filesRepo, + IBooksRepository booksRepo, + IFileSystem fileSystem, + ILogger logger) : IFileScannerService +{ + private static readonly HashSet SupportedExtensions = + [".epub", ".mobi", ".pdf", ".m4b", ".mp3", ".aac", ".flac"]; + + public async Task ScanAsync(CancellationToken cancellationToken = default) + { + var sources = await db.ImportSources + .Where(s => s.Enabled && s.Type == ImportSourceType.Folder) + .ToListAsync(cancellationToken); + + if (sources.Count == 0) + { + logger.LogDebug("No enabled folder sources configured — skipping scan"); + return; + } + + foreach (var source in sources) + { + if (!fileSystem.DirectoryExists(source.Path)) + { + logger.LogWarning("Source directory not found: {Path}", source.Path); + continue; + } + + logger.LogInformation("Scanning source '{Name}' at {Path}", source.Name, source.Path); + await ScanSourceAsync(source, cancellationToken); + } + + await AutoMatchAsync(cancellationToken); + } + + private async Task ScanSourceAsync(ImportSource source, CancellationToken ct) + { + IEnumerable allFiles; + try + { + allFiles = fileSystem.EnumerateFiles(source.Path, SearchOption.AllDirectories); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to enumerate files in {Path}", source.Path); + return; + } + + foreach (var fullPath in allFiles) + { + if (ct.IsCancellationRequested) break; + + var ext = System.IO.Path.GetExtension(fullPath); + var format = GetFormatFromExtension(ext); + if (format is null) continue; + + try + { + var hash = await fileSystem.ComputeSha256Async(fullPath, ct); + + var existing = await filesRepo.FindByHashAsync(hash); + if (existing is not null) + { + logger.LogDebug("Skipping duplicate: {Path}", fullPath); + continue; + } + + var relativePath = System.IO.Path.GetRelativePath(source.Path, fullPath); + var filename = System.IO.Path.GetFileName(fullPath); + var size = fileSystem.GetFileSize(fullPath); + + await filesRepo.AddAsync(new BookFile + { + SourceId = source.Id, + Path = relativePath, + Filename = filename, + SizeBytes = size, + Format = format.Value, + Hash = hash, + AddedAt = DateTime.UtcNow, + }); + + logger.LogInformation("Discovered: {Filename}", filename); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing file: {Path}", fullPath); + } + } + } + + private async Task AutoMatchAsync(CancellationToken ct) + { + var unmatched = (await filesRepo.GetUnmatchedAsync()).ToList(); + if (unmatched.Count == 0) return; + + var books = (await booksRepo.GetAllAsync()).ToList(); + + foreach (var file in unmatched) + { + if (ct.IsCancellationRequested) break; + + var matched = FindMatch(file, books); + if (matched is not null) + { + await filesRepo.AssignAsync(file.Id, matched.Id, null); + logger.LogInformation("Auto-matched '{Filename}' → '{Title}'", file.Filename, matched.Title); + } + } + } + + // ── Helpers (internal for unit testing) ────────────────────────────────── + + internal static FileFormat? GetFormatFromExtension(string ext) => + ext.ToLowerInvariant() switch + { + ".epub" => FileFormat.Epub, + ".mobi" => FileFormat.Mobi, + ".pdf" => FileFormat.Pdf, + ".m4b" => FileFormat.M4b, + ".mp3" => FileFormat.Mp3, + ".aac" => FileFormat.Aac, + ".flac" => FileFormat.Flac, + _ => null, + }; + + internal static Book? FindMatch(BookFile file, IEnumerable books) + { + var stem = System.IO.Path.GetFileNameWithoutExtension(file.Filename).ToLowerInvariant(); + + return books.FirstOrDefault(b => + stem.Contains(b.Title.ToLowerInvariant()) || + (b.Isbn is { Length: > 0 } isbn && stem.Contains(isbn))); + } +} diff --git a/PageManager.Api/PageManager.Api/Services/HardcoverService.cs b/PageManager.Api/PageManager.Api/Services/HardcoverService.cs index 664ee39..a3de6b1 100644 --- a/PageManager.Api/PageManager.Api/Services/HardcoverService.cs +++ b/PageManager.Api/PageManager.Api/Services/HardcoverService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using PageManager.Api.Api.Dtos; +using PageManager.Api.Data.Models; namespace PageManager.Api.Services; @@ -24,14 +25,31 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger e.Isbn).FirstOrDefault(x => x is not null); + var publisher = editions.Select(e => e.Publisher).FirstOrDefault(x => x is not null); + var coverUrl = editions.Select(e => e.CoverUrl).FirstOrDefault(x => x is not null); + var coverColor = editions.Select(e => e.CoverColor).FirstOrDefault(x => x is not null); return new HardcoverBookDetails { @@ -163,40 +186,53 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger ParseCachedContributors(JsonElement book) + private static List ParseContributions(JsonElement book) { - var authors = new List(); - if (!book.TryGetProperty("cached_contributors", out var el)) return authors; + var result = new List(); + if (!book.TryGetProperty("contributions", out var arr) || arr.ValueKind != JsonValueKind.Array) + return result; - if (el.ValueKind == JsonValueKind.String) + foreach (var c in arr.EnumerateArray()) { - if (el.GetString() is not string s) return authors; - using var inner = JsonDocument.Parse(s); - ExtractContributorNames(inner.RootElement, authors); - return authors; + string role = "Author"; + if (c.TryGetProperty("contribution", out var roleEl) && roleEl.ValueKind == JsonValueKind.String) + role = roleEl.GetString() ?? "Author"; + + if (!c.TryGetProperty("author", out var a) || a.ValueKind == JsonValueKind.Null) + continue; + + int? hardcoverId = a.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number + ? idEl.GetInt32() : null; + string name = a.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : ""; + if (string.IsNullOrEmpty(name)) continue; + + string? bio = a.TryGetProperty("bio", out var bioEl) && bioEl.ValueKind == JsonValueKind.String + ? bioEl.GetString() : null; + int? bornYear = a.TryGetProperty("born_year", out var byEl) && byEl.ValueKind == JsonValueKind.Number + ? byEl.GetInt32() : null; + string? slug = a.TryGetProperty("slug", out var slEl) && slEl.ValueKind == JsonValueKind.String + ? slEl.GetString() : null; + string? imageUrl = null; + if (a.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null) + if (imgEl.TryGetProperty("url", out var urlEl)) imageUrl = urlEl.GetString(); + + result.Add(new HardcoverAuthor + { + HardcoverId = hardcoverId, + Name = name, + Bio = bio, + BornYear = bornYear, + Slug = slug, + ImageUrl = imageUrl, + Role = role, + }); } - ExtractContributorNames(el, authors); - return authors; - } - - private static void ExtractContributorNames(JsonElement el, List authors) - { - if (el.ValueKind == JsonValueKind.Array) - { - foreach (var c in el.EnumerateArray()) - if (c.TryGetProperty("name", out var n) && n.GetString() is string name) - authors.Add(name); - } - else if (el.ValueKind == JsonValueKind.Object) - { - // Format: { "Author": [{name, ...}], "Narrator": [...] } - foreach (var role in el.EnumerateObject()) - ExtractContributorNames(role.Value, authors); - } + return result; } private static List ParseCachedTags(JsonElement book) @@ -239,30 +275,76 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger ParseEditions(JsonElement book) { - string? isbn = null, publisher = null, coverUrl = null, coverColor = null; + var result = new List(); - if (!book.TryGetProperty("editions", out var editions)) return (isbn, publisher, coverUrl, coverColor); + if (!book.TryGetProperty("editions", out var editions)) return result; foreach (var ed in editions.EnumerateArray()) { - if (isbn is null && ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null) - isbn = isbnEl.GetString(); + string? isbn = ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null + ? isbnEl.GetString() : null; - if (publisher is null && ed.TryGetProperty("publisher", out var pubEl) && pubEl.ValueKind != JsonValueKind.Null) + string? asin = ed.TryGetProperty("asin", out var asinEl) && asinEl.ValueKind != JsonValueKind.Null + ? asinEl.GetString() : null; + + int? pages = ed.TryGetProperty("pages", out var pagesEl) && pagesEl.ValueKind == JsonValueKind.Number + ? pagesEl.GetInt32() : null; + + int? releaseYear = ed.TryGetProperty("release_year", out var ryEl) && ryEl.ValueKind == JsonValueKind.Number + ? ryEl.GetInt32() : null; + + string? editionFormat = ed.TryGetProperty("edition_format", out var efEl) && efEl.ValueKind != JsonValueKind.Null + ? efEl.GetString() : null; + + int? audioSeconds = ed.TryGetProperty("audio_seconds", out var asEl) && asEl.ValueKind == JsonValueKind.Number + ? asEl.GetInt32() : null; + + ReadingFormat? readingFormat = null; + if (ed.TryGetProperty("reading_format_id", out var rfIdEl) && rfIdEl.ValueKind == JsonValueKind.Number) + { + var id = rfIdEl.GetInt32(); + if (Enum.IsDefined(typeof(ReadingFormat), id)) + readingFormat = (ReadingFormat)id; + } + + string? language = null, languageCode = null; + if (ed.TryGetProperty("language", out var langEl) && langEl.ValueKind != JsonValueKind.Null) + { + if (langEl.TryGetProperty("language", out var lnEl)) language = lnEl.GetString(); + if (langEl.TryGetProperty("code2", out var lcEl)) languageCode = lcEl.GetString(); + } + + string? publisher = null; + if (ed.TryGetProperty("publisher", out var pubEl) && pubEl.ValueKind != JsonValueKind.Null) if (pubEl.TryGetProperty("name", out var pnEl)) publisher = pnEl.GetString(); - if (coverUrl is null && ed.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null) + string? coverUrl = null, coverColor = null; + if (ed.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null) { if (imgEl.TryGetProperty("url", out var urlEl)) coverUrl = urlEl.GetString(); if (imgEl.TryGetProperty("color", out var colorEl)) coverColor = colorEl.GetString(); } - if (isbn is not null && publisher is not null && coverUrl is not null) break; + result.Add(new HardcoverEdition + { + Isbn = isbn, + Asin = asin, + Publisher = publisher, + ReleaseYear = releaseYear, + ReadingFormat = readingFormat, + EditionFormat = editionFormat, + Pages = pages, + AudioSeconds = audioSeconds, + Language = language, + LanguageCode = languageCode, + CoverUrl = coverUrl, + CoverColor = coverColor, + }); } - return (isbn, publisher, coverUrl, coverColor); + return result; } // ── HTTP ────────────────────────────────────────────────────────────────── diff --git a/PageManager.Api/PageManager.Api/Services/IAuthorsService.cs b/PageManager.Api/PageManager.Api/Services/IAuthorsService.cs new file mode 100644 index 0000000..a692ca8 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IAuthorsService.cs @@ -0,0 +1,9 @@ +using PageManager.Api.Api.Dtos; + +namespace PageManager.Api.Services; + +public interface IAuthorsService +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); +} diff --git a/PageManager.Api/PageManager.Api/Services/IBooksService.cs b/PageManager.Api/PageManager.Api/Services/IBooksService.cs index e485674..6f01747 100644 --- a/PageManager.Api/PageManager.Api/Services/IBooksService.cs +++ b/PageManager.Api/PageManager.Api/Services/IBooksService.cs @@ -13,4 +13,10 @@ public interface IBooksService /// Returns null if the Hardcover API does not recognise the id. /// Task CreateFromHardcoverAsync(int hardcoverId); + /// + /// Re-fetches metadata from Hardcover for an existing book and updates all fields, + /// authors, and editions in place. Returns null if the book has no hardcoverId + /// or the Hardcover API does not recognise it. + /// + Task RefreshFromHardcoverAsync(int id); } diff --git a/PageManager.Api/PageManager.Api/Services/IFileScannerService.cs b/PageManager.Api/PageManager.Api/Services/IFileScannerService.cs new file mode 100644 index 0000000..0b87d40 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IFileScannerService.cs @@ -0,0 +1,6 @@ +namespace PageManager.Api.Services; + +public interface IFileScannerService +{ + Task ScanAsync(CancellationToken cancellationToken = default); +} diff --git a/PageManager.Api/PageManager.Api/Services/IFileSystem.cs b/PageManager.Api/PageManager.Api/Services/IFileSystem.cs new file mode 100644 index 0000000..c6b2e9e --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IFileSystem.cs @@ -0,0 +1,9 @@ +namespace PageManager.Api.Services; + +public interface IFileSystem +{ + bool DirectoryExists(string path); + IEnumerable EnumerateFiles(string path, SearchOption searchOption); + long GetFileSize(string path); + Task ComputeSha256Async(string path, CancellationToken cancellationToken = default); +} diff --git a/PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs b/PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs new file mode 100644 index 0000000..c08047e --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; + +namespace PageManager.Api.Services; + +public class PhysicalFileSystem : IFileSystem +{ + public bool DirectoryExists(string path) => Directory.Exists(path); + + public IEnumerable EnumerateFiles(string path, SearchOption searchOption) => + Directory.EnumerateFiles(path, "*", searchOption); + + public long GetFileSize(string path) => new FileInfo(path).Length; + + public async Task ComputeSha256Async(string path, CancellationToken cancellationToken = default) + { + using var sha256 = SHA256.Create(); + await using var stream = File.OpenRead(path); + var hash = await sha256.ComputeHashAsync(stream, cancellationToken); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/PageManager.Api/PageManager.Api/appsettings.json b/PageManager.Api/PageManager.Api/appsettings.json index 0ddde1b..375f589 100644 --- a/PageManager.Api/PageManager.Api/appsettings.json +++ b/PageManager.Api/PageManager.Api/appsettings.json @@ -8,5 +8,6 @@ "AllowedHosts": "*", "Hardcover": { "ApiKey": "" - } + }, + "LibraryPaths": [] } diff --git a/PageManager.Web/index.html b/PageManager.Web/index.html index dd63787..5841491 100644 --- a/PageManager.Web/index.html +++ b/PageManager.Web/index.html @@ -6,8 +6,7 @@ PageManager - - +
diff --git a/PageManager.Web/package-lock.json b/PageManager.Web/package-lock.json index 4a6cfb0..ac6a929 100644 --- a/PageManager.Web/package-lock.json +++ b/PageManager.Web/package-lock.json @@ -8,6 +8,8 @@ "name": "pagemanager-web", "version": "0.0.1", "dependencies": { + "@ant-design/icons": "^6.1.1", + "antd": "^6.3.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" @@ -47,6 +49,99 @@ "node": ">=6.0.0" } }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.1.tgz", + "integrity": "sha512-AMT4N2y++TZETNHiM77fs4a0uPVCJGuL5MTonk13Pvv7UN7sID1cNEZOc1qNqx6zLKAOilTEFAdAoAFKa0U//Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -306,7 +401,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -485,6 +579,18 @@ "node": ">=18" } }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1016,6 +1122,725 @@ "node": ">=14" } }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.14.0.tgz", + "integrity": "sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.1.tgz", + "integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.8.4.tgz", + "integrity": "sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.0.tgz", + "integrity": "sha512-eUD5KKYnIZWmJwRA0vnyO/ovYUfHGU1svydY1OrqU5fw8Oz9Tdqvxvrlh0wl6xI/EW69dT7II49xpgOWzK3T5A==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^5.1.0", + "@rc-component/util": "^1.6.2", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.8.0.tgz", + "integrity": "sha512-Dr41bFevLB5NgVaJhEUmNvbEf+ynAhim6W98ZW2xvCsdFISc2TYP4ZvCVdie3eaZdum2kieVcvpNHu+UrzAAHA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.1.2.tgz", + "integrity": "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.6.0.tgz", + "integrity": "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/textarea": "~1.1.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.2.0.tgz", + "integrity": "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.2.tgz", + "integrity": "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-1.2.0.tgz", + "integrity": "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.0.tgz", + "integrity": "sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz", + "integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.9.1.tgz", + "integrity": "sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.0.tgz", + "integrity": "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz", + "integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz", + "integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.9.1.tgz", + "integrity": "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.1.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.7.0.tgz", + "integrity": "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/textarea": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/textarea/-/textarea-1.1.2.tgz", + "integrity": "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.3.0.tgz", + "integrity": "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.2.4.tgz", + "integrity": "sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.8.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.8.0.tgz", + "integrity": "sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.0.tgz", + "integrity": "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz", + "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.0.tgz", + "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.0.2.tgz", + "integrity": "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1765,6 +2590,70 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/antd": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.3.4.tgz", + "integrity": "sha512-Bu6JivPP7bFfYIdVj+61dxhwSOz+A3m0W7PlDasFGC3H3sNMYQ9gJXZoo11/rQh7pTlOQa351q5Ig/zjI98XYw==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.1.0", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.28.4", + "@rc-component/cascader": "~1.14.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.8.4", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.8.0", + "@rc-component/image": "~1.8.0", + "@rc-component/input": "~1.1.2", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.6.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.3.1", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~1.2.0", + "@rc-component/pagination": "~1.2.0", + "@rc-component/picker": "~1.9.1", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~1.1.1", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.6.15", + "@rc-component/slider": "~1.0.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.9.1", + "@rc-component/tabs": "~1.7.0", + "@rc-component/textarea": "~1.1.2", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.3.0", + "@rc-component/tree": "~1.2.4", + "@rc-component/tree-select": "~1.8.0", + "@rc-component/trigger": "^3.9.0", + "@rc-component/upload": "~1.1.0", + "@rc-component/util": "^1.10.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1932,6 +2821,15 @@ "node": ">= 16" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1952,6 +2850,12 @@ "dev": true, "license": "MIT" }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2012,7 +2916,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2029,6 +2932,12 @@ "node": ">=18" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2401,6 +3310,12 @@ "node": ">=8" } }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2545,6 +3460,15 @@ "node": ">=6" } }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3028,6 +3952,15 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3111,6 +4044,12 @@ "dev": true, "license": "MIT" }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3241,6 +4180,12 @@ "dev": true, "license": "MIT" }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3276,6 +4221,15 @@ "node": ">=18" } }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/PageManager.Web/package.json b/PageManager.Web/package.json index 017de52..1bcb4e9 100644 --- a/PageManager.Web/package.json +++ b/PageManager.Web/package.json @@ -12,17 +12,19 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@ant-design/icons": "^6.1.1", + "antd": "^6.3.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" }, "devDependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.4", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.1.1", "jsdom": "^26.1.0", "typescript": "~5.7.2", diff --git a/PageManager.Web/src/App.module.css b/PageManager.Web/src/App.module.css index 18985b7..6c9120a 100644 --- a/PageManager.Web/src/App.module.css +++ b/PageManager.Web/src/App.module.css @@ -1,20 +1 @@ -.shell { - display: flex; - height: 100%; - overflow: hidden; - background: var(--md-sys-color-background); -} - -.content { - flex: 1; - min-width: 0; - overflow: hidden; - display: flex; - flex-direction: column; - background: var(--md-sys-color-surface); -} - -.content > * { - flex: 1; - min-height: 0; -} +/* App layout is handled by Ant Design Layout component */ diff --git a/PageManager.Web/src/App.tsx b/PageManager.Web/src/App.tsx index 2b0cd3c..c4122eb 100644 --- a/PageManager.Web/src/App.tsx +++ b/PageManager.Web/src/App.tsx @@ -1,22 +1,44 @@ +import { ConfigProvider, Layout } from 'antd' import { Navigate, Route, Routes } from 'react-router-dom' import Sidebar from './components/Sidebar/Sidebar' import Library from './pages/Library/Library' +import BookDetail from './pages/BookDetail/BookDetail' +import Authors from './pages/Authors/Authors' +import AuthorDetail from './pages/AuthorDetail/AuthorDetail' import Import from './pages/Import/Import' import Metadata from './pages/Metadata/Metadata' -import s from './App.module.css' export default function App() { return ( -
- -
- - } /> - } /> - } /> - } /> - -
-
+ + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) } diff --git a/PageManager.Web/src/api/authors.ts b/PageManager.Web/src/api/authors.ts new file mode 100644 index 0000000..ce39bf1 --- /dev/null +++ b/PageManager.Web/src/api/authors.ts @@ -0,0 +1,10 @@ +import type { AuthorSummary, AuthorDetail } from '../types' +import { api } from './client' + +export function fetchAuthors(): Promise { + return api.get('/authors') +} + +export function fetchAuthor(id: number): Promise { + return api.get(`/authors/${id}`) +} diff --git a/PageManager.Web/src/api/books.ts b/PageManager.Web/src/api/books.ts index 77635b7..131099d 100644 --- a/PageManager.Web/src/api/books.ts +++ b/PageManager.Web/src/api/books.ts @@ -12,3 +12,7 @@ export function fetchBook(id: number): Promise { export function updateBook(id: number, patch: Partial): Promise { return api.put(`/books/${id}`, patch) } + +export function fetchMetadataFromHardcover(id: number): Promise { + return api.post(`/books/${id}/fetch-metadata`, {}) +} diff --git a/PageManager.Web/src/api/files.ts b/PageManager.Web/src/api/files.ts new file mode 100644 index 0000000..8551d4d --- /dev/null +++ b/PageManager.Web/src/api/files.ts @@ -0,0 +1,25 @@ +import type { BookFile } from '../types' + +export function fetchBookFiles(bookId: number): Promise { + return fetch(`/api/books/${bookId}/files`).then(r => r.json()) +} + +export function fetchUnmatchedFiles(): Promise { + return fetch('/api/files?unmatched=true').then(r => r.json()) +} + +export function assignFile(id: number, bookId: number | null, editionId: number | null): Promise { + return fetch(`/api/files/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bookId, editionId }), + }).then(r => r.json()) +} + +export function deleteFile(id: number): Promise { + return fetch(`/api/files/${id}`, { method: 'DELETE' }).then(() => undefined) +} + +export function triggerScan(): Promise { + return fetch('/api/scan', { method: 'POST' }).then(() => undefined) +} diff --git a/PageManager.Web/src/components/AddBookDialog/AddBookDialog.module.css b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.module.css index 0c553e9..ae41830 100644 --- a/PageManager.Web/src/components/AddBookDialog/AddBookDialog.module.css +++ b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.module.css @@ -1,194 +1 @@ -/* MD3 Full-screen scrim */ -.scrim { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, .5); - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - animation: fadeIn 150ms ease; -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -/* MD3 Dialog */ -.dialog { - width: 100%; - max-width: 560px; - max-height: 80vh; - background: var(--md-sys-color-surface-container-high); - border-radius: var(--md-sys-shape-xl); - display: flex; - flex-direction: column; - overflow: hidden; - animation: slideUp 200ms cubic-bezier(.3,0,0,1); -} - -@keyframes slideUp { - from { transform: translateY(24px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -.header { - display: flex; - align-items: center; - gap: 8px; - padding: 20px 24px 0; - flex-shrink: 0; -} - -.heading { - font: var(--md-sys-typescale-headline-small); - color: var(--md-sys-color-on-surface); - flex: 1; -} - -/* MD3 Icon Button */ -.closeBtn { - width: 40px; - height: 40px; - border-radius: var(--md-sys-shape-full); - display: flex; - align-items: center; - justify-content: center; - color: var(--md-sys-color-on-surface-variant); - position: relative; - overflow: hidden; -} -.closeBtn::before { - content: ''; - position: absolute; - inset: 0; - background: currentColor; - opacity: 0; - transition: opacity 200ms; -} -.closeBtn:hover::before { opacity: .08; } -.closeBtn:active::before { opacity: .12; } - -/* Search field */ -.searchWrap { - padding: 16px 24px 12px; - flex-shrink: 0; -} - -.search { - display: flex; - align-items: center; - gap: 12px; - height: 52px; - padding: 0 16px; - background: var(--md-sys-color-surface-container-highest); - border-radius: var(--md-sys-shape-full); -} - -.searchIcon { - color: var(--md-sys-color-on-surface-variant); - flex-shrink: 0; -} - -.searchInput { - flex: 1; - border: none; - background: transparent; - font: var(--md-sys-typescale-body-large); - color: var(--md-sys-color-on-surface); -} - -.searchInput::placeholder { - color: var(--md-sys-color-on-surface-variant); -} - -/* Results list */ -.results { - flex: 1; - overflow-y: auto; - padding: 0 8px 16px; -} - -.empty { - padding: 32px 16px; - text-align: center; - color: var(--md-sys-color-on-surface-variant); - font: var(--md-sys-typescale-body-large); -} - -/* Spinner */ -.spinner { - padding: 32px; - display: flex; - justify-content: center; - color: var(--md-sys-color-primary); -} - -/* MD3 List Item */ -.row { - display: flex; - align-items: center; - padding: 8px 16px; - border-radius: var(--md-sys-shape-md); - gap: 12px; - cursor: pointer; - position: relative; - overflow: hidden; -} - -.row::before { - content: ''; - position: absolute; - inset: 0; - background: var(--md-sys-color-on-surface); - opacity: 0; - transition: opacity 200ms; -} -.row:hover::before { opacity: .08; } -.row:active::before { opacity: .12; } - -.rowAdded { - cursor: default; -} - -.rowAdded::before { background: var(--md-sys-color-primary); } -.rowAdded:hover::before { opacity: .05; } - -.rowContent { - flex: 1; - min-width: 0; -} - -.rowTitle { - font: var(--md-sys-typescale-body-large); - color: var(--md-sys-color-on-surface); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.rowMeta { - font: var(--md-sys-typescale-body-small); - color: var(--md-sys-color-on-surface-variant); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-top: 1px; -} - -.rowAction { - flex-shrink: 0; - color: var(--md-sys-color-on-surface-variant); - font-size: 20px !important; - transition: color 200ms; -} - -.rowAdded .rowAction { - color: var(--md-sys-color-primary); -} - -.rowLoading { - cursor: default; - pointer-events: none; -} +/* AddBookDialog is implemented with Ant Design Modal */ diff --git a/PageManager.Web/src/components/AddBookDialog/AddBookDialog.tsx b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.tsx index 9c1ccf9..b857e63 100644 --- a/PageManager.Web/src/components/AddBookDialog/AddBookDialog.tsx +++ b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react' +import { Input, Modal, Spin, Typography } from 'antd' +import { CheckCircleOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons' import type { Book, HardcoverSearchResult } from '../../types' import { searchHardcover, addBookFromHardcover } from '../../api/search' -import s from './AddBookDialog.module.css' interface Props { onClose: () => void @@ -14,11 +15,10 @@ export default function AddBookDialog({ onClose, onAdded }: Props) { const [loading, setLoading] = useState(false) const [adding, setAdding] = useState(null) const [added, setAdded] = useState>(new Set()) - const inputRef = useRef(null) + const inputRef = useRef(null) useEffect(() => { inputRef.current?.focus() }, []) - // Debounced search useEffect(() => { if (!query.trim()) { setResults([]); return } const timer = setTimeout(() => { @@ -31,13 +31,6 @@ export default function AddBookDialog({ onClose, onAdded }: Props) { return () => clearTimeout(timer) }, [query]) - // Close on Escape - useEffect(() => { - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } - document.addEventListener('keydown', handler) - return () => document.removeEventListener('keydown', handler) - }, [onClose]) - async function handleAdd(result: HardcoverSearchResult) { if (adding !== null || added.has(result.id)) return setAdding(result.id) @@ -54,74 +47,85 @@ export default function AddBookDialog({ onClose, onAdded }: Props) { const showHint = !loading && !query.trim() return ( -
{ if (e.target === e.currentTarget) onClose() }}> -
-
- Add book - -
+ + setQuery(e.target.value)} + allowClear + style={{ marginBottom: 12 }} + /> -
-
- search - setQuery(e.target.value)} - /> +
+ {loading && ( +
+ } />
-
+ )} -
- {loading && ( -
- progress_activity -
- )} + {showHint && ( + + Start typing to search Hardcover + + )} - {showHint && ( -

Start typing to search Hardcover

- )} + {showEmpty && ( + + No results for "{query}" + + )} - {showEmpty && ( -

No results for "{query}"

- )} + {!loading && results.map(r => { + const isAdded = added.has(r.id) + const isAdding = adding === r.id - {!loading && results.map(r => { - const isAdded = added.has(r.id) - const isAdding = adding === r.id - - return ( -
handleAdd(r)} - role="button" - tabIndex={0} - onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }} - aria-label={`Add ${r.title}`} - > -
-
{r.title}
-
- {r.authors.join(', ')} - {r.year ? ` · ${r.year}` : ''} -
-
- - - {isAdding ? 'progress_activity' : isAdded ? 'check_circle' : 'add'} - + return ( +
handleAdd(r)} + role="button" + tabIndex={0} + onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '10px 8px', + borderRadius: 6, + cursor: isAdded ? 'default' : 'pointer', + background: isAdded ? '#f6f0ff' : 'transparent', + opacity: isAdding ? 0.6 : 1, + transition: 'background 150ms', + }} + onMouseEnter={e => { + if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'rgba(0,0,0,.03)' + }} + onMouseLeave={e => { + if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'transparent' + }} + > +
+ {r.title} + + {r.authors.join(', ')} + {r.year ? ` · ${r.year}` : ''} +
- ) - })} -
+ + {isAdding ? : isAdded ? : } + +
+ ) + })}
-
+ ) } diff --git a/PageManager.Web/src/components/BookCard/BookCard.module.css b/PageManager.Web/src/components/BookCard/BookCard.module.css index 12d97d4..a4f0cd6 100644 --- a/PageManager.Web/src/components/BookCard/BookCard.module.css +++ b/PageManager.Web/src/components/BookCard/BookCard.module.css @@ -1,41 +1,39 @@ -/* MD3 Elevated Card */ .card { display: flex; flex-direction: column; - background: var(--md-sys-color-surface-container-low); - border-radius: var(--md-sys-shape-md); - box-shadow: var(--md-sys-elevation-1); + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.06); overflow: hidden; cursor: pointer; position: relative; - transition: box-shadow 200ms cubic-bezier(.2,0,0,1); + transition: box-shadow 200ms, transform 150ms; outline: none; } .card:hover { - box-shadow: var(--md-sys-elevation-2); + box-shadow: 0 4px 12px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.06); } .card.selected { - box-shadow: var(--md-sys-elevation-2); - outline: 2px solid var(--md-sys-color-primary); + box-shadow: 0 4px 12px rgba(0,0,0,.12); + outline: 2px solid #6750A4; outline-offset: -2px; } -/* State layer for hover/press */ .stateLayer { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; - background: var(--md-sys-color-on-surface); + background: #000; opacity: 0; transition: opacity 200ms; } -.card:hover .stateLayer { opacity: .08; } -.card:active .stateLayer { opacity: .12; } -.card.selected .stateLayer { opacity: .08; background: var(--md-sys-color-primary); } +.card:hover .stateLayer { opacity: .04; } +.card:active .stateLayer { opacity: .08; } +.card.selected .stateLayer { opacity: .04; background: #6750A4; } .cover { aspect-ratio: 2/3; @@ -66,23 +64,39 @@ position: absolute; bottom: 8px; right: 8px; - background: rgba(0,0,0,.45); + background: rgba(0,0,0,.5); color: #fff; - font: var(--md-sys-typescale-label-small); + font-size: 11px; + font-weight: 500; padding: 2px 6px; - border-radius: var(--md-sys-shape-xs); + border-radius: 4px; } .body { padding: 8px 10px 10px; display: flex; flex-direction: column; - gap: 2px; + gap: 3px; +} + +.meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + flex-wrap: wrap; +} + +.year { + font-size: 11px; + color: rgba(0,0,0,.45); + flex-shrink: 0; } .title { - font: var(--md-sys-typescale-title-small); - color: var(--md-sys-color-on-surface); + font-size: 14px; + font-weight: 500; + color: rgba(0,0,0,.85); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; @@ -91,8 +105,8 @@ } .author { - font: var(--md-sys-typescale-body-small); - color: var(--md-sys-color-on-surface-variant); + font-size: 12px; + color: rgba(0,0,0,.45); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -101,19 +115,5 @@ .chips { display: flex; flex-wrap: wrap; - gap: 4px; - margin-top: 6px; -} - -/* MD3 Assist Chip */ -.chip { - height: 24px; - padding: 0 8px; - border-radius: var(--md-sys-shape-sm); - border: 1px solid var(--md-sys-color-outline-variant); - font: var(--md-sys-typescale-label-small); - color: var(--md-sys-color-on-surface-variant); - display: flex; - align-items: center; - background: transparent; + gap: 3px; } diff --git a/PageManager.Web/src/components/BookCard/BookCard.tsx b/PageManager.Web/src/components/BookCard/BookCard.tsx index 5a69f09..d674370 100644 --- a/PageManager.Web/src/components/BookCard/BookCard.tsx +++ b/PageManager.Web/src/components/BookCard/BookCard.tsx @@ -1,3 +1,4 @@ +import { Tag } from 'antd' import type { Book } from '../../types' import s from './BookCard.module.css' @@ -33,14 +34,18 @@ export default function BookCard({ book, onClick, selected }: Props) {

{book.title}

{book.authors.map(a => a.name).join(', ')}

-
- {book.formats.map(f => ( - {f.toUpperCase()} - ))} +
+ {book.year && {book.year}} +
+ {book.formats.map(f => ( + + {f.toUpperCase()} + + ))} +
- {/* MD3 state layer */}
) diff --git a/PageManager.Web/src/components/BookRow/BookRow.module.css b/PageManager.Web/src/components/BookRow/BookRow.module.css new file mode 100644 index 0000000..441bbd5 --- /dev/null +++ b/PageManager.Web/src/components/BookRow/BookRow.module.css @@ -0,0 +1,71 @@ +.row { + display: flex; + align-items: center; + gap: 14px; + padding: 8px 16px; + cursor: pointer; + position: relative; + overflow: hidden; + transition: background 150ms; + border-radius: 6px; + margin: 1px 8px; +} + +.row:hover { + background: rgba(0, 0, 0, 0.03); +} + +.row.selected { + background: #EDE7F6; +} + +.stateLayer { + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: #000; + opacity: 0; + transition: opacity 150ms; +} +.row:active .stateLayer { opacity: .06; } + +.cover { + width: 40px; + height: 56px; + border-radius: 4px; + flex-shrink: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.coverImg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.initials { + font-size: .75rem; + font-weight: 600; + color: rgba(255,255,255,.35); + letter-spacing: .04em; + user-select: none; +} + +.main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.right { + flex-shrink: 0; +} diff --git a/PageManager.Web/src/components/BookRow/BookRow.tsx b/PageManager.Web/src/components/BookRow/BookRow.tsx new file mode 100644 index 0000000..2454a90 --- /dev/null +++ b/PageManager.Web/src/components/BookRow/BookRow.tsx @@ -0,0 +1,67 @@ +import { Flex, Tag, Typography } from 'antd' +import type { Book } from '../../types' +import s from './BookRow.module.css' + +interface Props { + book: Book + onClick: (book: Book) => void + selected?: boolean +} + +export default function BookRow({ book, onClick, selected }: Props) { + const initials = book.title + .split(' ') + .slice(0, 2) + .map(w => w[0]) + .join('') + .toUpperCase() + + return ( +
onClick(book)} + > +
+ {book.coverUrl + ? + : {initials} + } +
+ +
+ + {book.title} + + + {book.authors.map(a => a.name).join(', ')} + + {book.series && ( + + {book.series.name} + · #{book.series.position} + + )} +
+ + + {book.year && ( + + {book.year} + + )} + + {book.formats.map(f => ( + + {f.toUpperCase()} + + ))} + + + +
+
+ ) +} diff --git a/PageManager.Web/src/components/DetailPanel/DetailPanel.module.css b/PageManager.Web/src/components/DetailPanel/DetailPanel.module.css index f3906d0..823464d 100644 --- a/PageManager.Web/src/components/DetailPanel/DetailPanel.module.css +++ b/PageManager.Web/src/components/DetailPanel/DetailPanel.module.css @@ -1,259 +1 @@ -/* MD3 Standard Side Sheet */ -.scrim { - display: none; /* hidden on wide screens; modal on narrow */ -} - -.sheet { - width: 360px; - min-width: 360px; - height: 100%; - background: var(--md-sys-color-surface-container-low); - display: flex; - flex-direction: column; - overflow-y: auto; - transform: translateX(100%); - transition: transform 250ms cubic-bezier(.3,0,0,1); - flex-shrink: 0; -} - -.sheet.open { - transform: translateX(0); -} - -.header { - display: flex; - align-items: center; - gap: 4px; - padding: 8px 8px 0; - flex-shrink: 0; -} - -/* MD3 Icon Button */ -.closeBtn { - width: 40px; - height: 40px; - border-radius: var(--md-sys-shape-full); - display: flex; - align-items: center; - justify-content: center; - color: var(--md-sys-color-on-surface-variant); - position: relative; - overflow: hidden; - transition: color 200ms; -} - -.closeBtn::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - background: var(--md-sys-color-on-surface-variant); - opacity: 0; - transition: opacity 200ms; -} - -.closeBtn:hover::before { opacity: .08; } -.closeBtn:active::before { opacity: .12; } - -.heading { - font: var(--md-sys-typescale-title-large); - color: var(--md-sys-color-on-surface); -} - -.cover { - margin: 12px 16px; - height: 180px; - border-radius: var(--md-sys-shape-md); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.coverImg { - height: 100%; - width: 100%; - object-fit: contain; - border-radius: var(--md-sys-shape-sm); -} - -.coverInitials { - font-size: 3rem; - font-weight: 300; - color: rgba(255,255,255,.3); - letter-spacing: .05em; -} - -.body { - padding: 0 16px 24px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.title { - font: var(--md-sys-typescale-headline-small); - color: var(--md-sys-color-on-surface); - margin-top: 4px; -} - -.author { - font: var(--md-sys-typescale-body-large); - color: var(--md-sys-color-on-surface-variant); -} - -.series { - font: var(--md-sys-typescale-label-large); - color: var(--md-sys-color-primary); -} - -.chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 4px; -} - -/* MD3 Suggestion Chip */ -.formatChip { - height: 32px; - padding: 0 12px; - border-radius: var(--md-sys-shape-sm); - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container-highest); - font: var(--md-sys-typescale-label-large); - color: var(--md-sys-color-on-surface); - display: flex; - align-items: center; -} - -.divider { - height: 1px; - background: var(--md-sys-color-outline-variant); - margin: 4px 0; -} - -.stats { - display: flex; - flex-direction: column; - gap: 12px; -} - -.stat { - display: flex; - align-items: flex-start; - gap: 12px; -} - -.statIcon { - color: var(--md-sys-color-on-surface-variant); - font-size: 20px !important; - margin-top: 2px; -} - -.statLabel { - font: var(--md-sys-typescale-label-small); - color: var(--md-sys-color-on-surface-variant); - text-transform: uppercase; - letter-spacing: .06em; -} - -.statValue { - font: var(--md-sys-typescale-body-large); - color: var(--md-sys-color-on-surface); -} - -.genres { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -/* MD3 Filter Chip */ -.genreChip { - height: 32px; - padding: 0 12px; - border-radius: var(--md-sys-shape-sm); - border: 1px solid var(--md-sys-color-outline-variant); - font: var(--md-sys-typescale-label-large); - color: var(--md-sys-color-on-surface-variant); - display: flex; - align-items: center; - background: transparent; -} - -.description { - font: var(--md-sys-typescale-body-medium); - color: var(--md-sys-color-on-surface-variant); - line-height: 1.6; - padding-top: 4px; -} - -.actions { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 8px; -} - -/* MD3 Filled Button */ -.btnFilled { - height: 40px; - padding: 0 24px; - border-radius: var(--md-sys-shape-full); - background: var(--md-sys-color-primary); - color: var(--md-sys-color-on-primary); - font: var(--md-sys-typescale-label-large); - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - position: relative; - overflow: hidden; - transition: box-shadow 200ms; -} - -.btnFilled .material-symbols-outlined { font-size: 18px !important; } - -.btnFilled::before { - content: ''; - position: absolute; - inset: 0; - background: var(--md-sys-color-on-primary); - opacity: 0; - transition: opacity 200ms; -} -.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); } -.btnFilled:hover::before { opacity: .08; } -.btnFilled:active::before { opacity: .12; } - -/* MD3 Filled Tonal Button */ -.btnTonal { - height: 40px; - padding: 0 24px; - border-radius: var(--md-sys-shape-full); - background: var(--md-sys-color-secondary-container); - color: var(--md-sys-color-on-secondary-container); - font: var(--md-sys-typescale-label-large); - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - position: relative; - overflow: hidden; - transition: box-shadow 200ms; -} - -.btnTonal .material-symbols-outlined { font-size: 18px !important; } - -.btnTonal::before { - content: ''; - position: absolute; - inset: 0; - background: var(--md-sys-color-on-secondary-container); - opacity: 0; - transition: opacity 200ms; -} -.btnTonal:hover { box-shadow: var(--md-sys-elevation-1); } -.btnTonal:hover::before { opacity: .08; } -.btnTonal:active::before { opacity: .12; } +/* DetailPanel is implemented with Ant Design Drawer */ diff --git a/PageManager.Web/src/components/DetailPanel/DetailPanel.tsx b/PageManager.Web/src/components/DetailPanel/DetailPanel.tsx index 77ddf5d..21b2426 100644 --- a/PageManager.Web/src/components/DetailPanel/DetailPanel.tsx +++ b/PageManager.Web/src/components/DetailPanel/DetailPanel.tsx @@ -1,97 +1,198 @@ -import type { Book } from '../../types' -import s from './DetailPanel.module.css' +import { Button, Divider, Drawer, Flex, Space, Tag, Typography } from 'antd' +import { + BankOutlined, + CalendarOutlined, + EditOutlined, + FileTextOutlined, + ReadOutlined, + TabletOutlined, + AudioOutlined, +} from '@ant-design/icons' +import type { Book, Edition, ReadingFormat } from '../../types' interface Props { book: Book | null onClose: () => void + onEditMetadata?: (book: Book) => void } -export default function DetailPanel({ book, onClose }: Props) { +export default function DetailPanel({ book, onClose, onEditMetadata }: Props) { return ( - <> - {book &&
} - - + + + {book.formats.map(f => ( + {f.toUpperCase()} + ))} + + + + +
+ {book.year && } label="Year" value={String(book.year)} />} + {book.pages && } label="Pages" value={String(book.pages)} />} + {book.publisher && } label="Publisher" value={book.publisher} />} +
+ + {book.genres.length > 0 && ( + + {book.genres.map(g => ( + {g} + ))} + + )} + + {book.description && ( + + {book.description} + + )} + + {book.editions.length > 0 && ( +
+ + Editions ({book.editions.length}) + +
+ {book.editions.map(ed => ( + + ))} +
+
+ )} +
+ + )} + ) } -function Stat({ icon, label, value }: { icon: string; label: string; value: string }) { +function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( -
- {icon} + + {icon}
-

{label}

-

{value}

+
+ {label} +
+
{value}
-
+ + ) +} + +const FORMAT_ICON: Record = { + Physical: , + Audio: , + Both: , + Ebook: , +} + +function formatAudio(seconds: number): string { + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + return h > 0 ? `${h}h ${m}m` : `${m}m` +} + +function EditionRow({ edition }: { edition: Edition }) { + const icon = edition.readingFormat ? FORMAT_ICON[edition.readingFormat] : + const label = edition.editionFormat ?? edition.readingFormat ?? null + + const details: string[] = [] + if (edition.publisher) details.push(edition.publisher) + if (edition.releaseYear) details.push(String(edition.releaseYear)) + if (edition.pages) details.push(`${edition.pages} pp`) + if (edition.audioSeconds) details.push(formatAudio(edition.audioSeconds)) + if (edition.language && edition.language !== 'English') details.push(edition.language) + + return ( + + {icon} +
+ {label && ( + {label} + )} + {(edition.isbn || edition.asin) && ( + + {edition.isbn ?? edition.asin} + + )} + {details.length > 0 && ( + + {details.join(' · ')} + + )} +
+
) } diff --git a/PageManager.Web/src/components/MetadataForm/MetadataForm.module.css b/PageManager.Web/src/components/MetadataForm/MetadataForm.module.css index 01b633a..840f6a6 100644 --- a/PageManager.Web/src/components/MetadataForm/MetadataForm.module.css +++ b/PageManager.Web/src/components/MetadataForm/MetadataForm.module.css @@ -1,224 +1 @@ -.form { - display: flex; - flex-direction: column; - gap: 16px; - padding: 16px 24px 24px; - overflow-y: auto; - flex: 1; -} - -.row { - display: flex; - gap: 12px; - align-items: flex-start; -} - -.field { - min-width: 0; -} - -.fieldFull { - flex: 1; -} - -/* ── MD3 Outlined Text Field ── */ -.inputWrap { - position: relative; - height: 56px; -} - -.textareaWrap { - height: auto; -} - -.input { - width: 100%; - height: 100%; - padding: 16px; - background: transparent; - border: none; - border-radius: var(--md-sys-shape-xs); - font: var(--md-sys-typescale-body-large); - color: var(--md-sys-color-on-surface); - outline: none; - position: relative; - z-index: 1; -} - -.textarea { - height: auto; - resize: vertical; - padding-top: 20px; -} - -/* The visible border is the fieldset */ -.fieldset { - position: absolute; - inset: -5px 0 0; - border: 1px solid var(--md-sys-color-outline); - border-radius: var(--md-sys-shape-xs); - pointer-events: none; - margin: 0; - padding: 0 8px; - transition: border-color 200ms, border-width 200ms; -} - -.legend { - font-size: .75rem; - line-height: 0; - padding: 0; - width: 0; /* collapsed by default; expands on focus/filled */ - overflow: hidden; - white-space: nowrap; - transition: width 200ms cubic-bezier(.2,0,0,1); - visibility: hidden; -} - -.label { - position: absolute; - left: 16px; - top: 50%; - transform: translateY(-50%); - font: var(--md-sys-typescale-body-large); - color: var(--md-sys-color-on-surface-variant); - pointer-events: none; - transition: top 150ms cubic-bezier(.2,0,0,1), - font-size 150ms cubic-bezier(.2,0,0,1), - line-height 150ms cubic-bezier(.2,0,0,1), - color 150ms; - z-index: 2; - background: transparent; -} - -.labelTextarea { - top: 20px; - transform: none; -} - -/* Floating label when input has value or is focused */ -.input:focus ~ .label, -.input:not(:placeholder-shown) ~ .label { - top: 0; - transform: translateY(-50%); - font-size: .75rem; - line-height: 1rem; - background: var(--md-sys-color-surface-container-low); - padding: 0 4px; - left: 12px; -} - -.input:focus ~ .label { - color: var(--md-sys-color-primary); -} - -.input:focus ~ .fieldset { - border-color: var(--md-sys-color-primary); - border-width: 2px; -} - -.input:focus ~ .fieldset .legend, -.input:not(:placeholder-shown) ~ .fieldset .legend { - width: auto; - padding: 0 2px; -} - -.textarea:focus ~ .label, -.textarea:not(:placeholder-shown) ~ .label { - top: 0; - transform: translateY(-50%); - font-size: .75rem; - line-height: 1rem; - background: var(--md-sys-color-surface-container-low); - padding: 0 4px; - left: 12px; -} - -.textarea:focus ~ .label { - color: var(--md-sys-color-primary); -} - -.textarea:focus ~ .fieldset { - border-color: var(--md-sys-color-primary); - border-width: 2px; -} - -.supporting { - font: var(--md-sys-typescale-body-small); - color: var(--md-sys-color-on-surface-variant); - padding: 4px 16px 0; -} - -/* ── Buttons ── */ -.footer { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 8px; - margin-top: auto; - padding-top: 8px; -} - -/* MD3 Outlined Button */ -.btnOutlined { - height: 40px; - padding: 0 24px; - border-radius: var(--md-sys-shape-full); - border: 1px solid var(--md-sys-color-outline); - font: var(--md-sys-typescale-label-large); - color: var(--md-sys-color-primary); - display: flex; - align-items: center; - gap: 8px; - position: relative; - overflow: hidden; - background: transparent; - transition: box-shadow 200ms; -} - -.btnOutlined .material-symbols-outlined { font-size: 18px !important; } - -.btnOutlined::before { - content: ''; - position: absolute; - inset: 0; - background: var(--md-sys-color-primary); - opacity: 0; - transition: opacity 200ms; -} -.btnOutlined:hover::before { opacity: .08; } -.btnOutlined:active::before { opacity: .12; } - -/* MD3 Filled Button */ -.btnFilled { - height: 40px; - padding: 0 24px; - border-radius: var(--md-sys-shape-full); - background: var(--md-sys-color-primary); - color: var(--md-sys-color-on-primary); - font: var(--md-sys-typescale-label-large); - display: flex; - align-items: center; - gap: 8px; - position: relative; - overflow: hidden; - transition: box-shadow 200ms, background 300ms; - min-width: 80px; - justify-content: center; -} - -.btnFilled .material-symbols-outlined { font-size: 18px !important; } - -.btnFilled::before { - content: ''; - position: absolute; - inset: 0; - background: var(--md-sys-color-on-primary); - opacity: 0; - transition: opacity 200ms; -} -.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); } -.btnFilled:hover::before { opacity: .08; } - -.btnSaved { - background: var(--md-sys-color-success); -} +/* MetadataForm uses Ant Design components with inline styles */ diff --git a/PageManager.Web/src/components/MetadataForm/MetadataForm.tsx b/PageManager.Web/src/components/MetadataForm/MetadataForm.tsx index a1865ec..783328f 100644 --- a/PageManager.Web/src/components/MetadataForm/MetadataForm.tsx +++ b/PageManager.Web/src/components/MetadataForm/MetadataForm.tsx @@ -1,21 +1,26 @@ -import { useEffect, useId, useState } from 'react' +import { useEffect, useState } from 'react' +import { Alert, Button, Flex, Input, Space } from 'antd' +import { CheckOutlined, SyncOutlined } from '@ant-design/icons' import type { Book } from '../../types' import { toForm } from './utils' import type { FormState } from './utils' -import s from './MetadataForm.module.css' interface Props { book: Book onSave: (patch: Partial) => void + onFetchMetadata?: () => Promise } -export default function MetadataForm({ book, onSave }: Props) { +export default function MetadataForm({ book, onSave, onFetchMetadata }: Props) { const [form, setForm] = useState(() => toForm(book)) const [saved, setSaved] = useState(false) + const [fetching, setFetching] = useState(false) + const [fetchError, setFetchError] = useState(null) useEffect(() => { setForm(toForm(book)) setSaved(false) + setFetchError(null) }, [book.id]) const set = (field: keyof FormState) => @@ -37,101 +42,104 @@ export default function MetadataForm({ book, onSave }: Props) { } return ( -
-
- -
-
- -
-
- - -
-
- - - -
-
- -
-
- + + +
+ + +
+
+ +
+ +
-
- - + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ {fetchError && ( + setFetchError(null)} + style={{ marginBottom: 12 }} + /> + )} + + + +
) } - -/* ── MD3 Outlined Text Field ─────────────────────────────── */ - -interface FieldProps { - label: string - value: string - onChange: (e: React.ChangeEvent) => void - grow?: boolean - width?: number - supporting?: string - type?: string -} - -function OutlinedField({ label, value, onChange, grow, width, supporting, type = 'text' }: FieldProps) { - const id = useId() - return ( -
-
- - -
{label}
-
- {supporting &&

{supporting}

} -
- ) -} - -interface TextareaProps { - label: string - value: string - onChange: (e: React.ChangeEvent) => void -} - -function OutlinedTextarea({ label, value, onChange }: TextareaProps) { - const id = useId() - return ( -
-
-