Changed design language. Added editions, better support for authors. Base for file handling

This commit is contained in:
2026-03-28 15:17:20 +02:00
parent cbd7f52535
commit 5acde17a53
84 changed files with 5861 additions and 1983 deletions
@@ -34,6 +34,7 @@ public static class BookFactory
HardcoverId = hardcoverId, HardcoverId = hardcoverId,
BookAuthors = [], BookAuthors = [],
SeriesEntries = [], SeriesEntries = [],
Editions = [],
}; };
} }
@@ -53,6 +54,13 @@ public static class BookFactory
return book; 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) 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 }; var series = new Series { Id = seriesId, Name = seriesName };
@@ -30,7 +30,7 @@ public class BooksControllerTests : IAsyncLifetime
using var scope = _factory.Services.CreateScope(); using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.ExecuteSqlRawAsync( 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() public Task DisposeAsync()
@@ -166,7 +166,7 @@ public class BooksControllerTests : IAsyncLifetime
Year = 1965, Year = 1965,
Publisher = "Ace Books", Publisher = "Ace Books",
Pages = 412, Pages = 412,
Authors = ["Frank Herbert"], Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
Genres = ["Science Fiction"], Genres = ["Science Fiction"],
Isbn = "9780441013593", Isbn = "9780441013593",
CoverColor = "#c4a35a", CoverColor = "#c4a35a",
@@ -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<AppDbContext>();
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<BookFileDto[]>();
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<BookFileDto[]>();
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<BookFileDto[]>();
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<BookFileDto>();
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<BookFileDto>();
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<AppDbContext>();
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<int> SeedBookAsync(string title = "Test Book")
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var book = new Book { Title = title, Formats = [], Genres = [] };
db.Books.Add(book);
await db.SaveChangesAsync();
return book.Id;
}
private async Task<int> 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<AppDbContext>();
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;
}
}
@@ -175,6 +175,39 @@ public class BooksServiceTests
result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]); 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 ────────────────────────────────────────────── // ── CreateFromHardcoverAsync ──────────────────────────────────────────────
[Fact] [Fact]
@@ -202,7 +235,7 @@ public class BooksServiceTests
result.Should().BeNull(); result.Should().BeNull();
await _repo.DidNotReceive().CreateBookAsync( await _repo.DidNotReceive().CreateBookAsync(
Arg.Any<PageManager.Api.Data.Models.Book>(), Arg.Any<PageManager.Api.Data.Models.Book>(),
Arg.Any<IReadOnlyList<string>>(), Arg.Any<IReadOnlyList<HardcoverAuthor>>(),
Arg.Any<(string, double, string?)?> ()); Arg.Any<(string, double, string?)?> ());
} }
@@ -219,7 +252,7 @@ public class BooksServiceTests
Publisher = "Ace Books", Publisher = "Ace Books",
Pages = 412, Pages = 412,
Description = "A sci-fi classic.", Description = "A sci-fi classic.",
Authors = ["Frank Herbert"], Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
Genres = ["Science Fiction"], Genres = ["Science Fiction"],
Isbn = "9780441013593", Isbn = "9780441013593",
CoverUrl = "https://example.com/cover.jpg", CoverUrl = "https://example.com/cover.jpg",
@@ -231,7 +264,7 @@ public class BooksServiceTests
var createdBook = BookFactory.Create(id: 55, title: "Dune") var createdBook = BookFactory.Create(id: 55, title: "Dune")
.WithAuthors((1, "Frank Herbert")) .WithAuthors((1, "Frank Herbert"))
.WithSeries(seriesName: "Dune Chronicles", position: 1.0); .WithSeries(seriesName: "Dune Chronicles", position: 1.0);
_repo.CreateBookAsync(Arg.Any<PageManager.Api.Data.Models.Book>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<(string, double, string?)?>()) _repo.CreateBookAsync(Arg.Any<PageManager.Api.Data.Models.Book>(), Arg.Any<IReadOnlyList<HardcoverAuthor>>(), Arg.Any<(string, double, string?)?>())
.Returns(createdBook); .Returns(createdBook);
var result = await _sut.CreateFromHardcoverAsync(123); var result = await _sut.CreateFromHardcoverAsync(123);
@@ -242,7 +275,7 @@ public class BooksServiceTests
result.Series!.Name.Should().Be("Dune Chronicles"); result.Series!.Name.Should().Be("Dune Chronicles");
await _repo.Received(1).CreateBookAsync( await _repo.Received(1).CreateBookAsync(
Arg.Is<PageManager.Api.Data.Models.Book>(b => b.HardcoverId == 123 && b.Title == "Dune"), Arg.Is<PageManager.Api.Data.Models.Book>(b => b.HardcoverId == 123 && b.Title == "Dune"),
Arg.Is<IReadOnlyList<string>>(a => a.Contains("Frank Herbert")), Arg.Is<IReadOnlyList<HardcoverAuthor>>(a => a.Any(ha => ha.Name == "Frank Herbert")),
Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles")); Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles"));
} }
} }
@@ -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();
}
}
@@ -4,4 +4,30 @@ public class AuthorDto
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = string.Empty; 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; } = [];
} }
@@ -1,5 +1,21 @@
namespace PageManager.Api.Api.Dtos; 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 class BookSeriesDto
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
@@ -23,4 +39,5 @@ public class BookDto
public string? CoverUrl { get; set; } public string? CoverUrl { get; set; }
public string? Isbn { get; set; } public string? Isbn { get; set; }
public int? HardcoverId { get; set; } public int? HardcoverId { get; set; }
public EditionDto[] Editions { get; set; } = [];
} }
@@ -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; }
}
@@ -9,6 +9,17 @@ public class HardcoverBookResult
public string[] Genres { get; set; } = []; 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 class HardcoverBookDetails
{ {
public int Id { get; set; } public int Id { get; set; }
@@ -16,13 +27,14 @@ public class HardcoverBookDetails
public string? Description { get; set; } public string? Description { get; set; }
public int? Pages { get; set; } public int? Pages { get; set; }
public int? Year { get; set; } public int? Year { get; set; }
public string[] Authors { get; set; } = []; public HardcoverAuthor[] Authors { get; set; } = [];
public string[] Genres { get; set; } = []; public string[] Genres { get; set; } = [];
public string? Isbn { get; set; } public string? Isbn { get; set; }
public string? Publisher { get; set; } public string? Publisher { get; set; }
public string? CoverUrl { get; set; } public string? CoverUrl { get; set; }
public string? CoverColor { get; set; } public string? CoverColor { get; set; }
public HardcoverSeriesInfo? Series { get; set; } public HardcoverSeriesInfo? Series { get; set; }
public HardcoverEdition[] Editions { get; set; } = [];
} }
public class HardcoverSeriesInfo public class HardcoverSeriesInfo
@@ -31,6 +43,22 @@ public class HardcoverSeriesInfo
public double Position { get; set; } 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 class CreateBookFromHardcoverRequest
{ {
public int HardcoverId { get; set; } public int HardcoverId { get; set; }
@@ -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<IEnumerable<AuthorSummaryDto>> GetAuthors() =>
await authors.GetAllAsync();
[HttpGet("{id}")]
public async Task<ActionResult<AuthorDetailDto>> GetAuthor(int id)
{
var author = await authors.GetByIdAsync(id);
return author is null ? NotFound() : Ok(author);
}
}
@@ -32,4 +32,11 @@ public class BooksController(IBooksService books) : ControllerBase
var book = await books.UpdateAsync(id, req); var book = await books.UpdateAsync(id, req);
return book is null ? NotFound() : Ok(book); return book is null ? NotFound() : Ok(book);
} }
[HttpPost("{id}/fetch-metadata")]
public async Task<ActionResult<BookDto>> FetchMetadata(int id)
{
var book = await books.RefreshFromHardcoverAsync(id);
return book is null ? NotFound() : Ok(book);
}
} }
@@ -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<IActionResult> GetByBook(int id)
{
var files = await filesRepo.GetByBookIdAsync(id);
return Ok(files.Select(ToDto));
}
// GET /api/files?unmatched=true
[HttpGet("files")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(int id)
{
var deleted = await filesRepo.DeleteAsync(id);
if (!deleted) return NotFound();
return NoContent();
}
// POST /api/scan
[HttpPost("scan")]
public async Task<IActionResult> 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,
};
}
@@ -10,8 +10,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<Series> Series => Set<Series>(); public DbSet<Series> Series => Set<Series>();
public DbSet<BookAuthor> BookAuthors => Set<BookAuthor>(); public DbSet<BookAuthor> BookAuthors => Set<BookAuthor>();
public DbSet<SeriesEntry> SeriesEntries => Set<SeriesEntry>(); public DbSet<SeriesEntry> SeriesEntries => Set<SeriesEntry>();
public DbSet<Edition> Editions => Set<Edition>();
public DbSet<ImportSource> ImportSources => Set<ImportSource>(); public DbSet<ImportSource> ImportSources => Set<ImportSource>();
public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>(); public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>();
public DbSet<BookFile> BookFiles => Set<BookFile>();
protected override void OnModelCreating(ModelBuilder model) protected override void OnModelCreating(ModelBuilder model)
{ {
@@ -28,8 +30,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.WithMany(a => a.BookAuthors) .WithMany(a => a.BookAuthors)
.HasForeignKey(ba => ba.AuthorId); .HasForeignKey(ba => ba.AuthorId);
e.Property(ba => ba.Role) // Role is stored as plain text — no conversion needed
.HasConversion<string>();
}); });
// ── SeriesEntry (composite PK) ─────────────────────────────────────── // ── SeriesEntry (composite PK) ───────────────────────────────────────
@@ -66,6 +67,17 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
e.HasIndex(b => b.Isbn); e.HasIndex(b => b.Isbn);
}); });
// ── Edition ──────────────────────────────────────────────────────────
model.Entity<Edition>(e =>
{
e.HasOne(ed => ed.Book)
.WithMany(b => b.Editions)
.HasForeignKey(ed => ed.BookId);
e.Property(ed => ed.ReadingFormat)
.HasConversion<string>();
});
// ── Author ─────────────────────────────────────────────────────────── // ── Author ───────────────────────────────────────────────────────────
model.Entity<Author>(e => model.Entity<Author>(e =>
{ {
@@ -85,5 +97,29 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
e.Property(i => i.Id).ValueGeneratedNever(); e.Property(i => i.Id).ValueGeneratedNever();
e.Property(i => i.Status).HasConversion<string>(); e.Property(i => i.Status).HasConversion<string>();
}); });
// ── BookFile ──────────────────────────────────────────────────────────
model.Entity<BookFile>(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<string>();
e.HasIndex(f => f.Hash);
e.HasIndex(f => f.BookId);
e.HasIndex(f => f.EditionId);
});
} }
} }
@@ -3,7 +3,12 @@ namespace PageManager.Api.Data.Models;
public class Author public class Author
{ {
public int Id { get; set; } public int Id { get; set; }
public int? HardcoverId { get; set; }
public string Name { get; set; } = string.Empty; 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<BookAuthor> BookAuthors { get; set; } = []; public ICollection<BookAuthor> BookAuthors { get; set; } = [];
} }
@@ -27,4 +27,6 @@ public class Book
public ICollection<BookAuthor> BookAuthors { get; set; } = []; public ICollection<BookAuthor> BookAuthors { get; set; } = [];
public ICollection<SeriesEntry> SeriesEntries { get; set; } = []; public ICollection<SeriesEntry> SeriesEntries { get; set; } = [];
public ICollection<Edition> Editions { get; set; } = [];
public ICollection<BookFile> BookFiles { get; set; } = [];
} }
@@ -1,7 +1,5 @@
namespace PageManager.Api.Data.Models; namespace PageManager.Api.Data.Models;
public enum AuthorRole { Author, Editor }
public class BookAuthor public class BookAuthor
{ {
public int BookId { get; set; } public int BookId { get; set; }
@@ -10,5 +8,5 @@ public class BookAuthor
public int AuthorId { get; set; } public int AuthorId { get; set; }
public Author Author { get; set; } = null!; public Author Author { get; set; } = null!;
public AuthorRole Role { get; set; } = AuthorRole.Author; public string Role { get; set; } = "Author";
} }
@@ -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; }
/// <summary>Path relative to the source root directory.</summary>
public string Path { get; set; } = string.Empty;
public string Filename { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public FileFormat Format { get; set; }
/// <summary>SHA-256 hex digest — used for deduplication.</summary>
public string? Hash { get; set; }
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
}
@@ -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; }
/// <summary>Detailed edition format (e.g. "Hardcover", "Mass Market Paperback").</summary>
public string? EditionFormat { get; set; }
// Content
public int? Pages { get; set; }
/// <summary>Audiobook duration in seconds.</summary>
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<BookFile> BookFiles { get; set; } = [];
}
@@ -0,0 +1,12 @@
namespace PageManager.Api.Data.Models;
public enum FileFormat
{
Epub,
Mobi,
Pdf,
M4b,
Mp3,
Aac,
Flac,
}
@@ -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,
}
@@ -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<IEnumerable<Author>> GetAllAsync() =>
db.Authors
.Include(a => a.BookAuthors)
.OrderBy(a => a.Name)
.ToListAsync()
.ContinueWith(t => (IEnumerable<Author>)t.Result);
public Task<Author?> 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);
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models; using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories; namespace PageManager.Api.Data.Repositories;
@@ -9,6 +10,7 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
db.Books db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.ToListAsync() .ToListAsync()
.ContinueWith(t => (IEnumerable<Book>)t.Result); .ContinueWith(t => (IEnumerable<Book>)t.Result);
@@ -16,34 +18,28 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
db.Books db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.FirstOrDefaultAsync(b => b.Id == id); .FirstOrDefaultAsync(b => b.Id == id);
public Task<Book?> FindByHardcoverIdAsync(int hardcoverId) => public Task<Book?> FindByHardcoverIdAsync(int hardcoverId) =>
db.Books db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId); .FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId);
public async Task<Book> CreateBookAsync( public async Task<Book> CreateBookAsync(
Book book, Book book,
IReadOnlyList<string> authorNames, IReadOnlyList<HardcoverAuthor> authors,
(string name, double position, string? arc)? series) (string name, double position, string? arc)? series)
{ {
db.Books.Add(book); db.Books.Add(book);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Resolve / create authors // Resolve / create authors and link to book
var authors = new List<Author>(); var resolved = await ResolveAuthorsAsync(authors);
foreach (var name in authorNames) foreach (var (author, role) in resolved)
{ db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id, Role = role });
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 series // Resolve / create series
if (series is { } si) if (series is { } si)
@@ -66,8 +62,79 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
return await db.Books return await db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series) .Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.FirstAsync(b => b.Id == book.Id);
}
public async Task<Book> SyncHardcoverDataAsync(Book book, IReadOnlyList<HardcoverAuthor> authors, IReadOnlyList<Edition> 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); .FirstAsync(b => b.Id == book.Id);
} }
public Task SaveAsync() => db.SaveChangesAsync(); public Task SaveAsync() => db.SaveChangesAsync();
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<List<(Author author, string role)>> ResolveAuthorsAsync(IReadOnlyList<HardcoverAuthor> 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;
}
} }
@@ -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<IEnumerable<BookFile>> GetByBookIdAsync(int bookId) =>
db.BookFiles
.Where(f => f.BookId == bookId)
.OrderBy(f => f.Filename)
.ToListAsync()
.ContinueWith(t => (IEnumerable<BookFile>)t.Result);
public Task<IEnumerable<BookFile>> GetUnmatchedAsync() =>
db.BookFiles
.Where(f => f.BookId == null)
.OrderBy(f => f.Filename)
.ToListAsync()
.ContinueWith(t => (IEnumerable<BookFile>)t.Result);
public Task<BookFile?> GetByIdAsync(int id) =>
db.BookFiles.FirstOrDefaultAsync(f => f.Id == id);
public Task<BookFile?> FindByHashAsync(string hash) =>
db.BookFiles.FirstOrDefaultAsync(f => f.Hash == hash);
public async Task<BookFile> AddAsync(BookFile file)
{
db.BookFiles.Add(file);
await db.SaveChangesAsync();
return file;
}
public async Task<BookFile?> 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<bool> 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;
}
}
@@ -0,0 +1,9 @@
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public interface IAuthorsRepository
{
Task<IEnumerable<Author>> GetAllAsync();
Task<Author?> GetByIdAsync(int id);
}
@@ -1,3 +1,4 @@
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models; using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories; namespace PageManager.Api.Data.Repositories;
@@ -9,7 +10,8 @@ public interface IBooksRepository
Task<Book?> FindByHardcoverIdAsync(int hardcoverId); Task<Book?> FindByHardcoverIdAsync(int hardcoverId);
Task<Book> CreateBookAsync( Task<Book> CreateBookAsync(
Book book, Book book,
IReadOnlyList<string> authorNames, IReadOnlyList<HardcoverAuthor> authors,
(string name, double position, string? arc)? series); (string name, double position, string? arc)? series);
Task<Book> SyncHardcoverDataAsync(Book book, IReadOnlyList<HardcoverAuthor> authors, IReadOnlyList<Edition> editions);
Task SaveAsync(); Task SaveAsync();
} }
@@ -0,0 +1,14 @@
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public interface IFilesRepository
{
Task<IEnumerable<BookFile>> GetByBookIdAsync(int bookId);
Task<IEnumerable<BookFile>> GetUnmatchedAsync();
Task<BookFile?> GetByIdAsync(int id);
Task<BookFile?> FindByHashAsync(string hash);
Task<BookFile> AddAsync(BookFile file);
Task<BookFile?> AssignAsync(int id, int? bookId, int? editionId);
Task<bool> DeleteAsync(int id);
}
@@ -0,0 +1,375 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasColumnType("text")
.HasColumnName("color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.PrimitiveCollection<string[]>("Formats")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("formats");
b.PrimitiveCollection<string[]>("Genres")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("genres");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<int?>("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<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int>("AuthorId")
.HasColumnType("integer")
.HasColumnName("author_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("CoverColor")
.HasColumnType("text")
.HasColumnName("cover_color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<string>("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<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<long>("DownloadedBytes")
.HasColumnType("bigint")
.HasColumnName("downloaded_bytes");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.Property<string>("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<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("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<int>("SeriesId")
.HasColumnType("integer")
.HasColumnName("series_id");
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Arc")
.HasColumnType("text")
.HasColumnName("arc");
b.Property<double>("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
}
}
}
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace PageManager.Api.Migrations
{
/// <inheritdoc />
public partial class AddEditions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "editions",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
book_id = table.Column<int>(type: "integer", nullable: false),
isbn = table.Column<string>(type: "text", nullable: true),
publisher = table.Column<string>(type: "text", nullable: true),
cover_url = table.Column<string>(type: "text", nullable: true),
cover_color = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "editions");
}
}
}
@@ -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
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "asin",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "audio_seconds",
table: "editions",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "edition_format",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "language",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "language_code",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "pages",
table: "editions",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "reading_format",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "reading_format_id",
table: "editions",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "release_year",
table: "editions",
type: "integer",
nullable: true);
}
/// <inheritdoc />
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");
}
}
}
@@ -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
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "reading_format_id",
table: "editions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "reading_format_id",
table: "editions",
type: "integer",
nullable: true);
}
}
}
@@ -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<int>(
name: "hardcover_id",
table: "authors",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "bio",
table: "authors",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "born_year",
table: "authors",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "image_url",
table: "authors",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
}
}
@@ -0,0 +1,532 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<int?>("BornYear")
.HasColumnType("integer")
.HasColumnName("born_year");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasColumnType("text")
.HasColumnName("color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.PrimitiveCollection<string[]>("Formats")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("formats");
b.PrimitiveCollection<string[]>("Genres")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("genres");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<int?>("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<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int>("AuthorId")
.HasColumnType("integer")
.HasColumnName("author_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("added_at");
b.Property<int?>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int?>("EditionId")
.HasColumnType("integer")
.HasColumnName("edition_id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<string>("Format")
.IsRequired()
.HasColumnType("text")
.HasColumnName("format")
.HasConversion<string>();
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Asin")
.HasColumnType("text")
.HasColumnName("asin");
b.Property<int?>("AudioSeconds")
.HasColumnType("integer")
.HasColumnName("audio_seconds");
b.Property<string>("CoverColor")
.HasColumnType("text")
.HasColumnName("cover_color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("EditionFormat")
.HasColumnType("text")
.HasColumnName("edition_format");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<string>("Language")
.HasColumnType("text")
.HasColumnName("language");
b.Property<string>("LanguageCode")
.HasColumnType("text")
.HasColumnName("language_code");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("ReadingFormat")
.HasColumnType("text")
.HasColumnName("reading_format")
.HasConversion<string>();
b.Property<int?>("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<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<long>("DownloadedBytes")
.HasColumnType("bigint")
.HasColumnName("downloaded_bytes");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.Property<string>("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<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("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<int>("SeriesId")
.HasColumnType("integer")
.HasColumnName("series_id");
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Arc")
.HasColumnType("text")
.HasColumnName("arc");
b.Property<double>("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
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace PageManager.Api.Migrations
{
/// <inheritdoc />
public partial class AddBookFiles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "book_files",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
book_id = table.Column<int>(type: "integer", nullable: true),
edition_id = table.Column<int>(type: "integer", nullable: true),
source_id = table.Column<string>(type: "text", nullable: true),
path = table.Column<string>(type: "text", nullable: false),
filename = table.Column<string>(type: "text", nullable: false),
size_bytes = table.Column<long>(type: "bigint", nullable: false),
format = table.Column<string>(type: "text", nullable: false),
hash = table.Column<string>(type: "text", nullable: true),
added_at = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "book_files");
}
}
}
@@ -1,4 +1,5 @@
// <auto-generated /> // <auto-generated />
using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -30,11 +31,31 @@ namespace PageManager.Api.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<int?>("BornYear")
.HasColumnType("integer")
.HasColumnName("born_year");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string>("Slug")
.HasColumnType("text")
.HasColumnName("slug");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_authors"); .HasName("pk_authors");
@@ -134,6 +155,144 @@ namespace PageManager.Api.Migrations
b.ToTable("book_authors", (string)null); b.ToTable("book_authors", (string)null);
}); });
modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("added_at");
b.Property<int?>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int?>("EditionId")
.HasColumnType("integer")
.HasColumnName("edition_id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<string>("Format")
.IsRequired()
.HasColumnType("text")
.HasColumnName("format")
.HasConversion<string>();
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Asin")
.HasColumnType("text")
.HasColumnName("asin");
b.Property<int?>("AudioSeconds")
.HasColumnType("integer")
.HasColumnName("audio_seconds");
b.Property<string>("CoverColor")
.HasColumnType("text")
.HasColumnName("cover_color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("EditionFormat")
.HasColumnType("text")
.HasColumnName("edition_format");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<string>("Language")
.HasColumnType("text")
.HasColumnName("language");
b.Property<string>("LanguageCode")
.HasColumnType("text")
.HasColumnName("language_code");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("ReadingFormat")
.HasColumnType("text")
.HasColumnName("reading_format")
.HasConversion<string>();
b.Property<int?>("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 => modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -277,6 +436,45 @@ namespace PageManager.Api.Migrations
b.Navigation("Book"); 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 => modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{ {
b.HasOne("PageManager.Api.Data.Models.Book", "Book") b.HasOne("PageManager.Api.Data.Models.Book", "Book")
@@ -307,9 +505,20 @@ namespace PageManager.Api.Migrations
{ {
b.Navigation("BookAuthors"); b.Navigation("BookAuthors");
b.Navigation("BookFiles");
b.Navigation("Editions");
b.Navigation("SeriesEntries"); 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 => modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{ {
b.Navigation("Entries"); b.Navigation("Entries");
@@ -7,6 +7,12 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>PageManager.Api.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
@@ -33,8 +33,14 @@ builder.Host.UseSerilog((ctx, services, cfg) => cfg
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddScoped<IBooksRepository, BooksRepository>(); builder.Services.AddScoped<IBooksRepository, BooksRepository>();
builder.Services.AddScoped<IBooksService, BooksService>(); builder.Services.AddScoped<IBooksService, BooksService>();
builder.Services.AddScoped<IAuthorsRepository, AuthorsRepository>();
builder.Services.AddScoped<IAuthorsService, AuthorsService>();
builder.Services.AddHttpClient<IHardcoverService, HardcoverService>(); builder.Services.AddHttpClient<IHardcoverService, HardcoverService>();
builder.Services.AddScoped<IImportService, ImportService>(); builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IFilesRepository, FilesRepository>();
builder.Services.AddScoped<IFileScannerService, FileScannerService>();
builder.Services.AddSingleton<IFileSystem, PhysicalFileSystem>();
builder.Services.AddHostedService<FileScannerBackgroundService>();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
@@ -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<IEnumerable<AuthorSummaryDto>> 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<AuthorDetailDto?> 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(),
};
}
}
@@ -63,6 +63,21 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
CoverUrl = details.CoverUrl, CoverUrl = details.CoverUrl,
Isbn = details.Isbn, Isbn = details.Isbn,
HardcoverId = details.Id, 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 (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); return ToDto(created);
} }
private static BookDto ToDto(Book book) public async Task<BookDto?> 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(); var entry = book.SeriesEntries.FirstOrDefault();
return new BookDto return new BookDto
@@ -87,7 +142,16 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
Color = book.Color, Color = book.Color,
Genres = book.Genres, Genres = book.Genres,
Authors = book.BookAuthors 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(), .ToArray(),
Series = entry is null ? null : new BookSeriesDto Series = entry is null ? null : new BookSeriesDto
{ {
@@ -98,6 +162,21 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
CoverUrl = book.CoverUrl, CoverUrl = book.CoverUrl,
Isbn = book.Isbn, Isbn = book.Isbn,
HardcoverId = book.HardcoverId, 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(),
}; };
} }
} }
@@ -0,0 +1,34 @@
namespace PageManager.Api.Services;
public class FileScannerBackgroundService(
IServiceScopeFactory scopeFactory,
IHostApplicationLifetime lifetime,
ILogger<FileScannerBackgroundService> 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<IFileScannerService>();
await scanner.ScanAsync(stoppingToken);
logger.LogInformation("Initial library scan complete");
}
catch (OperationCanceledException)
{
// Normal shutdown
}
catch (Exception ex)
{
logger.LogError(ex, "Library scan failed");
}
}
}
@@ -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<FileScannerService> logger) : IFileScannerService
{
private static readonly HashSet<string> 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<string> 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<Book> 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)));
}
}
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PageManager.Api.Api.Dtos; using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Services; namespace PageManager.Api.Services;
@@ -24,14 +25,31 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
description description
pages pages
release_year release_year
cached_contributors
cached_tags cached_tags
contributions(where: {contributable_type: {_eq: "Book"}}, limit: 8) {
contribution
author {
id
name
bio
born_year
slug
image { url }
}
}
book_series(order_by: {position: asc}, limit: 1) { book_series(order_by: {position: asc}, limit: 1) {
position position
series { id name } series { id name }
} }
editions(order_by: {users_count: desc}, limit: 3) { editions(order_by: {users_count: desc}, limit: 10) {
isbn_13 isbn_13
asin
pages
release_year
edition_format
audio_seconds
reading_format_id
language { language code2 }
publisher { name } publisher { name }
image { url color } image { url color }
} }
@@ -86,9 +104,9 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
var details = ParseBookDetails(books[0]); var details = ParseBookDetails(books[0]);
if (details.Authors.Length == 0) if (details.Authors.Length == 0)
logger.LogWarning("No authors parsed for book id={Id}. cached_contributors: {Raw}", logger.LogWarning("No authors parsed for book id={Id}. contributions: {Raw}",
hardcoverId, hardcoverId,
books[0].TryGetProperty("cached_contributors", out var cc) ? cc.GetRawText() : "missing"); books[0].TryGetProperty("contributions", out var cc) ? cc.GetRawText() : "missing");
return details; return details;
} }
@@ -144,10 +162,15 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
int? pages = book.TryGetProperty("pages", out var pEl) && pEl.ValueKind == JsonValueKind.Number ? pEl.GetInt32() : null; int? pages = book.TryGetProperty("pages", out var pEl) && pEl.ValueKind == JsonValueKind.Number ? pEl.GetInt32() : null;
int? year = book.TryGetProperty("release_year",out var yEl) && yEl.ValueKind == JsonValueKind.Number ? yEl.GetInt32() : null; int? year = book.TryGetProperty("release_year",out var yEl) && yEl.ValueKind == JsonValueKind.Number ? yEl.GetInt32() : null;
var authors = ParseCachedContributors(book); var authors = ParseContributions(book);
var genres = ParseCachedTags(book); var genres = ParseCachedTags(book);
var series = ParseBookSeries(book); var series = ParseBookSeries(book);
var (isbn, publisher, coverUrl, coverColor) = ParseEditions(book); var editions = ParseEditions(book);
var isbn = editions.Select(e => 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 return new HardcoverBookDetails
{ {
@@ -163,40 +186,53 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
CoverUrl = coverUrl, CoverUrl = coverUrl,
CoverColor = coverColor, CoverColor = coverColor,
Series = series, Series = series,
Editions = [.. editions],
}; };
} }
private static List<string> ParseCachedContributors(JsonElement book) private static List<HardcoverAuthor> ParseContributions(JsonElement book)
{ {
var authors = new List<string>(); var result = new List<HardcoverAuthor>();
if (!book.TryGetProperty("cached_contributors", out var el)) return authors; 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; string role = "Author";
using var inner = JsonDocument.Parse(s); if (c.TryGetProperty("contribution", out var roleEl) && roleEl.ValueKind == JsonValueKind.String)
ExtractContributorNames(inner.RootElement, authors); role = roleEl.GetString() ?? "Author";
return authors;
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 result;
return authors;
}
private static void ExtractContributorNames(JsonElement el, List<string> 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);
}
} }
private static List<string> ParseCachedTags(JsonElement book) private static List<string> ParseCachedTags(JsonElement book)
@@ -239,30 +275,76 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
return seriesName is null ? null : new HardcoverSeriesInfo { Name = seriesName, Position = position }; return seriesName is null ? null : new HardcoverSeriesInfo { Name = seriesName, Position = position };
} }
private static (string? isbn, string? publisher, string? coverUrl, string? coverColor) ParseEditions(JsonElement book) private static List<HardcoverEdition> ParseEditions(JsonElement book)
{ {
string? isbn = null, publisher = null, coverUrl = null, coverColor = null; var result = new List<HardcoverEdition>();
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()) foreach (var ed in editions.EnumerateArray())
{ {
if (isbn is null && ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null) string? isbn = ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null
isbn = isbnEl.GetString(); ? 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 (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("url", out var urlEl)) coverUrl = urlEl.GetString();
if (imgEl.TryGetProperty("color", out var colorEl)) coverColor = colorEl.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 ────────────────────────────────────────────────────────────────── // ── HTTP ──────────────────────────────────────────────────────────────────
@@ -0,0 +1,9 @@
using PageManager.Api.Api.Dtos;
namespace PageManager.Api.Services;
public interface IAuthorsService
{
Task<IEnumerable<AuthorSummaryDto>> GetAllAsync();
Task<AuthorDetailDto?> GetByIdAsync(int id);
}
@@ -13,4 +13,10 @@ public interface IBooksService
/// Returns null if the Hardcover API does not recognise the id. /// Returns null if the Hardcover API does not recognise the id.
/// </summary> /// </summary>
Task<BookDto?> CreateFromHardcoverAsync(int hardcoverId); Task<BookDto?> CreateFromHardcoverAsync(int hardcoverId);
/// <summary>
/// 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.
/// </summary>
Task<BookDto?> RefreshFromHardcoverAsync(int id);
} }
@@ -0,0 +1,6 @@
namespace PageManager.Api.Services;
public interface IFileScannerService
{
Task ScanAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
namespace PageManager.Api.Services;
public interface IFileSystem
{
bool DirectoryExists(string path);
IEnumerable<string> EnumerateFiles(string path, SearchOption searchOption);
long GetFileSize(string path);
Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken = default);
}
@@ -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<string> EnumerateFiles(string path, SearchOption searchOption) =>
Directory.EnumerateFiles(path, "*", searchOption);
public long GetFileSize(string path) => new FileInfo(path).Length;
public async Task<string> 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();
}
}
@@ -8,5 +8,6 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"Hardcover": { "Hardcover": {
"ApiKey": "" "ApiKey": ""
} },
"LibraryPaths": []
} }
+1 -2
View File
@@ -6,8 +6,7 @@
<title>PageManager</title> <title>PageManager</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+956 -2
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -12,17 +12,19 @@
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.1",
"antd": "^6.3.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": "^7.1.1"
}, },
"devDependencies": { "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/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@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", "@vitest/coverage-v8": "^3.1.1",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"typescript": "~5.7.2", "typescript": "~5.7.2",
+1 -20
View File
@@ -1,20 +1 @@
.shell { /* App layout is handled by Ant Design Layout component */
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;
}
+27 -5
View File
@@ -1,22 +1,44 @@
import { ConfigProvider, Layout } from 'antd'
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import Sidebar from './components/Sidebar/Sidebar' import Sidebar from './components/Sidebar/Sidebar'
import Library from './pages/Library/Library' 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 Import from './pages/Import/Import'
import Metadata from './pages/Metadata/Metadata' import Metadata from './pages/Metadata/Metadata'
import s from './App.module.css'
export default function App() { export default function App() {
return ( return (
<div className={s.shell}> <ConfigProvider
theme={{
token: {
colorPrimary: '#6750A4',
borderRadius: 8,
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
colorBgBase: '#ffffff',
colorBgLayout: '#f5f5f5',
},
components: {
Layout: { siderBg: '#F7F2FA' },
Menu: { itemBg: 'transparent' },
},
}}
>
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sidebar /> <Sidebar />
<div className={s.content}> <Layout.Content style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/library" replace />} /> <Route path="/" element={<Navigate to="/library" replace />} />
<Route path="/library" element={<Library />} /> <Route path="/library" element={<Library />} />
<Route path="/books/:id" element={<BookDetail />} />
<Route path="/authors" element={<Authors />} />
<Route path="/authors/:id" element={<AuthorDetail />} />
<Route path="/import" element={<Import />} /> <Route path="/import" element={<Import />} />
<Route path="/metadata" element={<Metadata />} /> <Route path="/metadata" element={<Metadata />} />
</Routes> </Routes>
</div> </Layout.Content>
</div> </Layout>
</ConfigProvider>
) )
} }
+10
View File
@@ -0,0 +1,10 @@
import type { AuthorSummary, AuthorDetail } from '../types'
import { api } from './client'
export function fetchAuthors(): Promise<AuthorSummary[]> {
return api.get<AuthorSummary[]>('/authors')
}
export function fetchAuthor(id: number): Promise<AuthorDetail> {
return api.get<AuthorDetail>(`/authors/${id}`)
}
+4
View File
@@ -12,3 +12,7 @@ export function fetchBook(id: number): Promise<Book> {
export function updateBook(id: number, patch: Partial<Book>): Promise<Book> { export function updateBook(id: number, patch: Partial<Book>): Promise<Book> {
return api.put<Book>(`/books/${id}`, patch) return api.put<Book>(`/books/${id}`, patch)
} }
export function fetchMetadataFromHardcover(id: number): Promise<Book> {
return api.post<Book>(`/books/${id}/fetch-metadata`, {})
}
+25
View File
@@ -0,0 +1,25 @@
import type { BookFile } from '../types'
export function fetchBookFiles(bookId: number): Promise<BookFile[]> {
return fetch(`/api/books/${bookId}/files`).then(r => r.json())
}
export function fetchUnmatchedFiles(): Promise<BookFile[]> {
return fetch('/api/files?unmatched=true').then(r => r.json())
}
export function assignFile(id: number, bookId: number | null, editionId: number | null): Promise<BookFile> {
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<void> {
return fetch(`/api/files/${id}`, { method: 'DELETE' }).then(() => undefined)
}
export function triggerScan(): Promise<void> {
return fetch('/api/scan', { method: 'POST' }).then(() => undefined)
}
@@ -1,194 +1 @@
/* MD3 Full-screen scrim */ /* AddBookDialog is implemented with Ant Design Modal */
.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;
}
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react' 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 type { Book, HardcoverSearchResult } from '../../types'
import { searchHardcover, addBookFromHardcover } from '../../api/search' import { searchHardcover, addBookFromHardcover } from '../../api/search'
import s from './AddBookDialog.module.css'
interface Props { interface Props {
onClose: () => void onClose: () => void
@@ -14,11 +15,10 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState<number | null>(null) const [adding, setAdding] = useState<number | null>(null)
const [added, setAdded] = useState<Set<number>>(new Set()) const [added, setAdded] = useState<Set<number>>(new Set())
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<any>(null)
useEffect(() => { inputRef.current?.focus() }, []) useEffect(() => { inputRef.current?.focus() }, [])
// Debounced search
useEffect(() => { useEffect(() => {
if (!query.trim()) { setResults([]); return } if (!query.trim()) { setResults([]); return }
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -31,13 +31,6 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [query]) }, [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) { async function handleAdd(result: HardcoverSearchResult) {
if (adding !== null || added.has(result.id)) return if (adding !== null || added.has(result.id)) return
setAdding(result.id) setAdding(result.id)
@@ -54,42 +47,40 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
const showHint = !loading && !query.trim() const showHint = !loading && !query.trim()
return ( return (
<div className={s.scrim} onClick={e => { if (e.target === e.currentTarget) onClose() }}> <Modal
<div className={s.dialog} role="dialog" aria-modal="true" aria-label="Add book"> open
<div className={s.header}> onCancel={onClose}
<span className={s.heading}>Add book</span> title="Add book"
<button className={s.closeBtn} onClick={onClose} aria-label="Close"> footer={null}
<span className="material-symbols-outlined">close</span> width={520}
</button> >
</div> <Input
<div className={s.searchWrap}>
<div className={s.search}>
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
<input
ref={inputRef} ref={inputRef}
className={s.searchInput}
type="search" type="search"
placeholder="Search by title or author…" placeholder="Search by title or author…"
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
allowClear
style={{ marginBottom: 12 }}
/> />
</div>
</div>
<div className={s.results}> <div style={{ minHeight: 200, maxHeight: 400, overflowY: 'auto' }}>
{loading && ( {loading && (
<div className={s.spinner}> <div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<span className="material-symbols-outlined">progress_activity</span> <Spin indicator={<LoadingOutlined spin />} />
</div> </div>
)} )}
{showHint && ( {showHint && (
<p className={s.empty}>Start typing to search Hardcover</p> <Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
Start typing to search Hardcover
</Typography.Text>
)} )}
{showEmpty && ( {showEmpty && (
<p className={s.empty}>No results for "{query}"</p> <Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
No results for "{query}"
</Typography.Text>
)} )}
{!loading && results.map(r => { {!loading && results.map(r => {
@@ -99,29 +90,42 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
return ( return (
<div <div
key={r.id} key={r.id}
className={`${s.row} ${isAdded ? s.rowAdded : ''} ${isAdding ? s.rowLoading : ''}`}
onClick={() => handleAdd(r)} onClick={() => handleAdd(r)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }} onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }}
aria-label={`Add ${r.title}`} 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'
}}
> >
<div className={s.rowContent}> <div style={{ minWidth: 0 }}>
<div className={s.rowTitle}>{r.title}</div> <Typography.Text strong ellipsis style={{ display: 'block' }}>{r.title}</Typography.Text>
<div className={s.rowMeta}> <Typography.Text type="secondary" ellipsis style={{ fontSize: 13, display: 'block' }}>
{r.authors.join(', ')} {r.authors.join(', ')}
{r.year ? ` · ${r.year}` : ''} {r.year ? ` · ${r.year}` : ''}
</Typography.Text>
</div> </div>
</div> <span style={{ marginLeft: 12, color: isAdded ? '#6750A4' : 'rgba(0,0,0,.45)', fontSize: 18, flexShrink: 0, display: 'flex' }}>
{isAdding ? <LoadingOutlined spin /> : isAdded ? <CheckCircleOutlined /> : <PlusOutlined />}
<span className={`material-symbols-outlined ${s.rowAction}`}>
{isAdding ? 'progress_activity' : isAdded ? 'check_circle' : 'add'}
</span> </span>
</div> </div>
) )
})} })}
</div> </div>
</div> </Modal>
</div>
) )
} }
@@ -1,41 +1,39 @@
/* MD3 Elevated Card */
.card { .card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--md-sys-color-surface-container-low); background: #fff;
border-radius: var(--md-sys-shape-md); border-radius: 8px;
box-shadow: var(--md-sys-elevation-1); box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.06);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: box-shadow 200ms cubic-bezier(.2,0,0,1); transition: box-shadow 200ms, transform 150ms;
outline: none; outline: none;
} }
.card:hover { .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 { .card.selected {
box-shadow: var(--md-sys-elevation-2); box-shadow: 0 4px 12px rgba(0,0,0,.12);
outline: 2px solid var(--md-sys-color-primary); outline: 2px solid #6750A4;
outline-offset: -2px; outline-offset: -2px;
} }
/* State layer for hover/press */
.stateLayer { .stateLayer {
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
background: var(--md-sys-color-on-surface); background: #000;
opacity: 0; opacity: 0;
transition: opacity 200ms; transition: opacity 200ms;
} }
.card:hover .stateLayer { opacity: .08; } .card:hover .stateLayer { opacity: .04; }
.card:active .stateLayer { opacity: .12; } .card:active .stateLayer { opacity: .08; }
.card.selected .stateLayer { opacity: .08; background: var(--md-sys-color-primary); } .card.selected .stateLayer { opacity: .04; background: #6750A4; }
.cover { .cover {
aspect-ratio: 2/3; aspect-ratio: 2/3;
@@ -66,23 +64,39 @@
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
background: rgba(0,0,0,.45); background: rgba(0,0,0,.5);
color: #fff; color: #fff;
font: var(--md-sys-typescale-label-small); font-size: 11px;
font-weight: 500;
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--md-sys-shape-xs); border-radius: 4px;
} }
.body { .body {
padding: 8px 10px 10px; padding: 8px 10px 10px;
display: flex; display: flex;
flex-direction: column; 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 { .title {
font: var(--md-sys-typescale-title-small); font-size: 14px;
color: var(--md-sys-color-on-surface); font-weight: 500;
color: rgba(0,0,0,.85);
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -91,8 +105,8 @@
} }
.author { .author {
font: var(--md-sys-typescale-body-small); font-size: 12px;
color: var(--md-sys-color-on-surface-variant); color: rgba(0,0,0,.45);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -101,19 +115,5 @@
.chips { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 3px;
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;
} }
@@ -1,3 +1,4 @@
import { Tag } from 'antd'
import type { Book } from '../../types' import type { Book } from '../../types'
import s from './BookCard.module.css' import s from './BookCard.module.css'
@@ -33,14 +34,18 @@ export default function BookCard({ book, onClick, selected }: Props) {
<div className={s.body}> <div className={s.body}>
<p className={s.title}>{book.title}</p> <p className={s.title}>{book.title}</p>
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p> <p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p>
<div className={s.meta}>
{book.year && <span className={s.year}>{book.year}</span>}
<div className={s.chips}> <div className={s.chips}>
{book.formats.map(f => ( {book.formats.map(f => (
<span key={f} className={s.chip}>{f.toUpperCase()}</span> <Tag key={f} style={{ fontSize: 11, lineHeight: '20px', padding: '0 5px', margin: 0 }}>
{f.toUpperCase()}
</Tag>
))} ))}
</div> </div>
</div> </div>
</div>
{/* MD3 state layer */}
<div className={s.stateLayer} /> <div className={s.stateLayer} />
</article> </article>
) )
@@ -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;
}
@@ -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 (
<div
className={`${s.row} ${selected ? s.selected : ''}`}
onClick={() => onClick(book)}
>
<div className={s.cover} style={{ background: book.color }}>
{book.coverUrl
? <img className={s.coverImg} src={book.coverUrl} alt="" loading="lazy" />
: <span className={s.initials}>{initials}</span>
}
</div>
<div className={s.main}>
<Typography.Text
ellipsis
style={{ display: 'block', fontSize: 14, fontWeight: 500, color: selected ? '#6750A4' : 'rgba(0,0,0,.85)' }}
>
{book.title}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
{book.series && (
<Typography.Text ellipsis style={{ display: 'block', fontSize: 11, color: '#6750A4', marginTop: 2 }}>
{book.series.name}
<span style={{ color: 'rgba(0,0,0,.45)' }}> · #{book.series.position}</span>
</Typography.Text>
)}
</div>
<Flex align="center" gap={8} className={s.right}>
{book.year && (
<Typography.Text type="secondary" style={{ fontSize: 12, minWidth: 36, textAlign: 'right' }}>
{book.year}
</Typography.Text>
)}
<Flex gap={4}>
{book.formats.map(f => (
<Tag key={f} style={{ margin: 0, fontSize: 11, padding: '0 5px', lineHeight: '20px' }}>
{f.toUpperCase()}
</Tag>
))}
</Flex>
</Flex>
<div className={s.stateLayer} />
</div>
)
}
@@ -1,259 +1 @@
/* MD3 Standard Side Sheet */ /* DetailPanel is implemented with Ant Design Drawer */
.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; }
@@ -1,97 +1,198 @@
import type { Book } from '../../types' import { Button, Divider, Drawer, Flex, Space, Tag, Typography } from 'antd'
import s from './DetailPanel.module.css' import {
BankOutlined,
CalendarOutlined,
EditOutlined,
FileTextOutlined,
ReadOutlined,
TabletOutlined,
AudioOutlined,
} from '@ant-design/icons'
import type { Book, Edition, ReadingFormat } from '../../types'
interface Props { interface Props {
book: Book | null book: Book | null
onClose: () => void onClose: () => void
onEditMetadata?: (book: Book) => void
} }
export default function DetailPanel({ book, onClose }: Props) { export default function DetailPanel({ book, onClose, onEditMetadata }: Props) {
return ( return (
<> <Drawer
{book && <div className={s.scrim} onClick={onClose} />} open={!!book}
<aside className={`${s.sheet} ${book ? s.open : ''}`}> onClose={onClose}
width={340}
title="Book details"
placement="right"
styles={{
header: { borderBottom: '1px solid #f0f0f0', padding: '12px 20px' },
body: { padding: 0, overflowY: 'auto' },
}}
extra={
book && (
<Space>
<Button disabled icon={<ReadOutlined />} size="small">Open</Button>
<Button
type="primary"
icon={<EditOutlined />}
size="small"
onClick={() => onEditMetadata?.(book)}
>
Edit Metadata
</Button>
</Space>
)
}
>
{book && ( {book && (
<> <>
<div className={s.header}> {/* Cover */}
<button className={s.closeBtn} onClick={onClose} aria-label="Close"> <div style={{
<span className="material-symbols-outlined">close</span> height: 190,
</button> background: book.color,
<h2 className={s.heading}>Book details</h2> display: 'flex',
</div> alignItems: 'center',
justifyContent: 'center',
<div className={s.cover} style={{ background: book.color }}> overflow: 'hidden',
}}>
{book.coverUrl {book.coverUrl
? <img className={s.coverImg} src={book.coverUrl} alt={book.title} /> ? <img
: <span className={s.coverInitials}> src={book.coverUrl}
alt={book.title}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
: <span style={{ fontSize: '3rem', fontWeight: 300, color: 'rgba(255,255,255,.3)', letterSpacing: '.05em' }}>
{book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()} {book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()}
</span> </span>
} }
</div> </div>
<div className={s.body}> {/* Body */}
<h3 className={s.title}>{book.title}</h3> <div style={{ padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p> <div>
<Typography.Title level={4} style={{ margin: 0, lineHeight: 1.3 }}>{book.title}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
{book.series && ( {book.series && (
<p className={s.series}> <div style={{ marginTop: 4 }}>
<Typography.Text style={{ fontSize: 13, color: '#6750A4' }}>
{book.series.name} · Book {book.series.position} {book.series.name} · Book {book.series.position}
{book.series.arc ? ` · ${book.series.arc}` : ''} {book.series.arc ? ` · ${book.series.arc}` : ''}
</p> </Typography.Text>
</div>
)} )}
<div className={s.chips}>
{book.formats.map(f => (
<span key={f} className={s.formatChip}>{f.toUpperCase()}</span>
))}
</div> </div>
<div className={s.divider} /> <Flex gap={6} wrap="wrap">
{book.formats.map(f => (
<Tag key={f}>{f.toUpperCase()}</Tag>
))}
</Flex>
<div className={s.stats}> <Divider style={{ margin: '4px 0' }} />
{book.year && <Stat icon="calendar_today" label="Year" value={String(book.year)} />}
{book.pages && <Stat icon="menu_book" label="Pages" value={String(book.pages)} />} <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{book.publisher && <Stat icon="business" label="Publisher" value={book.publisher} />} {book.year && <StatRow icon={<CalendarOutlined />} label="Year" value={String(book.year)} />}
{book.pages && <StatRow icon={<FileTextOutlined />} label="Pages" value={String(book.pages)} />}
{book.publisher && <StatRow icon={<BankOutlined />} label="Publisher" value={book.publisher} />}
</div> </div>
{book.genres.length > 0 && ( {book.genres.length > 0 && (
<div className={s.genres}> <Flex gap={6} wrap="wrap">
{book.genres.map(g => ( {book.genres.map(g => (
<span key={g} className={s.genreChip}>{g}</span> <Tag key={g} color="purple">{g}</Tag>
))} ))}
</div> </Flex>
)} )}
{book.description && ( {book.description && (
<p className={s.description}>{book.description}</p> <Typography.Paragraph style={{ fontSize: 13, color: 'rgba(0,0,0,.65)', lineHeight: 1.6, marginBottom: 0 }}>
{book.description}
</Typography.Paragraph>
)} )}
<div className={s.actions}> {book.editions.length > 0 && (
<button className={s.btnFilled}>
<span className="material-symbols-outlined">menu_book</span>
Open
</button>
<button className={s.btnTonal}>
<span className="material-symbols-outlined">edit</span>
Edit Metadata
</button>
</div>
</div>
</>
)}
</aside>
</>
)
}
function Stat({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className={s.stat}>
<span className={`material-symbols-outlined ${s.statIcon}`}>{icon}</span>
<div> <div>
<p className={s.statLabel}>{label}</p> <Typography.Text
<p className={s.statValue}>{value}</p> type="secondary"
style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.06em' }}
>
Editions ({book.editions.length})
</Typography.Text>
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
{book.editions.map(ed => (
<EditionRow key={ed.id} edition={ed} />
))}
</div> </div>
</div> </div>
)}
</div>
</>
)}
</Drawer>
)
}
function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Flex align="flex-start" gap={10}>
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 16, marginTop: 1, display: 'flex' }}>{icon}</span>
<div>
<div style={{ fontSize: 11, color: 'rgba(0,0,0,.45)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
{label}
</div>
<div style={{ fontSize: 14, color: 'rgba(0,0,0,.85)' }}>{value}</div>
</div>
</Flex>
)
}
const FORMAT_ICON: Record<ReadingFormat, React.ReactNode> = {
Physical: <ReadOutlined />,
Audio: <AudioOutlined />,
Both: <ReadOutlined />,
Ebook: <TabletOutlined />,
}
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] : <ReadOutlined />
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 (
<Flex gap={8} align="flex-start">
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 16, display: 'flex', marginTop: 2 }}>{icon}</span>
<div>
{label && (
<Typography.Text style={{ fontSize: 12, fontWeight: 500 }}>{label}</Typography.Text>
)}
{(edition.isbn || edition.asin) && (
<Typography.Text
type="secondary"
style={{ fontSize: 11, fontFamily: 'monospace', display: 'block' }}
>
{edition.isbn ?? edition.asin}
</Typography.Text>
)}
{details.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
{details.join(' · ')}
</Typography.Text>
)}
</div>
</Flex>
) )
} }
@@ -1,224 +1 @@
.form { /* MetadataForm uses Ant Design components with inline styles */
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);
}
@@ -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 type { Book } from '../../types'
import { toForm } from './utils' import { toForm } from './utils'
import type { FormState } from './utils' import type { FormState } from './utils'
import s from './MetadataForm.module.css'
interface Props { interface Props {
book: Book book: Book
onSave: (patch: Partial<Book>) => void onSave: (patch: Partial<Book>) => void
onFetchMetadata?: () => Promise<void>
} }
export default function MetadataForm({ book, onSave }: Props) { export default function MetadataForm({ book, onSave, onFetchMetadata }: Props) {
const [form, setForm] = useState<FormState>(() => toForm(book)) const [form, setForm] = useState<FormState>(() => toForm(book))
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [fetching, setFetching] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setForm(toForm(book)) setForm(toForm(book))
setSaved(false) setSaved(false)
setFetchError(null)
}, [book.id]) }, [book.id])
const set = (field: keyof FormState) => const set = (field: keyof FormState) =>
@@ -37,101 +42,104 @@ export default function MetadataForm({ book, onSave }: Props) {
} }
return ( return (
<form className={s.form} onSubmit={handleSave}> <form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className={s.row}> <Flex gap={12}>
<OutlinedField label="Title" value={form.title} onChange={set('title')} grow /> <div style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Title</label>
<Input value={form.title} onChange={set('title')} />
</div> </div>
<div className={s.row}> </Flex>
<OutlinedField label="Author(s)" value={form.authors} onChange={set('authors')} grow
supporting="Comma-separated" /> <div>
</div> <label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>
<div className={s.row}> Author(s) <span style={{ fontStyle: 'italic' }}>(comma-separated)</span>
<OutlinedField label="Series" value={form.series} onChange={set('series')} grow /> </label>
<OutlinedField label="Position" value={form.seriesPosition} onChange={set('seriesPosition')} width={96} type="number" /> <Input value={form.authors} onChange={set('authors')} />
</div>
<div className={s.row}>
<OutlinedField label="Publisher" value={form.publisher} onChange={set('publisher')} grow />
<OutlinedField label="Year" value={form.year} onChange={set('year')} width={90} type="number" />
<OutlinedField label="Pages" value={form.pages} onChange={set('pages')} width={90} type="number" />
</div>
<div className={s.row}>
<OutlinedField label="Genres" value={form.genres} onChange={set('genres')} grow
supporting="Comma-separated" />
</div>
<div className={s.row}>
<OutlinedTextarea label="Description" value={form.description} onChange={set('description')} />
</div> </div>
<div className={s.footer}> <Flex gap={12}>
<button type="button" className={s.btnOutlined}> <div style={{ flex: 1 }}>
<span className="material-symbols-outlined">sync</span> <label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Series</label>
Fetch Metadata <Input value={form.series} onChange={set('series')} />
</button> </div>
<button type="submit" className={`${s.btnFilled} ${saved ? s.btnSaved : ''}`}> <div style={{ width: 96 }}>
{saved <label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Position</label>
? <><span className="material-symbols-outlined">check</span> Saved</> <Input type="number" value={form.seriesPosition} onChange={set('seriesPosition')} />
: 'Save'} </div>
</button> </Flex>
<Flex gap={12}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Publisher</label>
<Input value={form.publisher} onChange={set('publisher')} />
</div>
<div style={{ width: 90 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Year</label>
<Input type="number" value={form.year} onChange={set('year')} />
</div>
<div style={{ width: 90 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Pages</label>
<Input type="number" value={form.pages} onChange={set('pages')} />
</div>
</Flex>
<div>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>
Genres <span style={{ fontStyle: 'italic' }}>(comma-separated)</span>
</label>
<Input value={form.genres} onChange={set('genres')} />
</div>
<div>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Description</label>
<Input.TextArea
value={form.description}
onChange={set('description')}
rows={5}
style={{ resize: 'vertical' }}
/>
</div>
<div style={{ paddingTop: 4, borderTop: '1px solid #f0f0f0' }}>
{fetchError && (
<Alert
message={fetchError}
type="error"
showIcon
closable
onClose={() => setFetchError(null)}
style={{ marginBottom: 12 }}
/>
)}
<Space>
<Button
icon={fetching ? <SyncOutlined spin /> : <SyncOutlined />}
loading={fetching}
disabled={!onFetchMetadata}
onClick={async () => {
if (!onFetchMetadata) return
setFetching(true)
setFetchError(null)
try {
await onFetchMetadata()
} catch (err) {
setFetchError(err instanceof Error ? err.message : 'Failed to fetch metadata')
} finally {
setFetching(false)
}
}}
>
{fetching ? 'Fetching…' : 'Fetch Metadata'}
</Button>
<Button
type="primary"
htmlType="submit"
icon={saved ? <CheckOutlined /> : undefined}
>
{saved ? 'Saved' : 'Save'}
</Button>
</Space>
</div> </div>
</form> </form>
) )
} }
/* ── MD3 Outlined Text Field ─────────────────────────────── */
interface FieldProps {
label: string
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
grow?: boolean
width?: number
supporting?: string
type?: string
}
function OutlinedField({ label, value, onChange, grow, width, supporting, type = 'text' }: FieldProps) {
const id = useId()
return (
<div className={s.field} style={{ flex: grow ? 1 : undefined, width: width }}>
<div className={s.inputWrap}>
<input
id={id}
className={s.input}
type={type}
value={value}
onChange={onChange}
placeholder=" "
/>
<label htmlFor={id} className={s.label}>{label}</label>
<fieldset className={s.fieldset} aria-hidden><legend className={s.legend}>{label}</legend></fieldset>
</div>
{supporting && <p className={s.supporting}>{supporting}</p>}
</div>
)
}
interface TextareaProps {
label: string
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}
function OutlinedTextarea({ label, value, onChange }: TextareaProps) {
const id = useId()
return (
<div className={`${s.field} ${s.fieldFull}`}>
<div className={`${s.inputWrap} ${s.textareaWrap}`}>
<textarea
id={id}
className={`${s.input} ${s.textarea}`}
value={value}
onChange={onChange}
placeholder=" "
rows={5}
/>
<label htmlFor={id} className={`${s.label} ${s.labelTextarea}`}>{label}</label>
<fieldset className={s.fieldset} aria-hidden><legend className={s.legend}>{label}</legend></fieldset>
</div>
</div>
)
}
@@ -1,104 +1 @@
/* MD3 List Item */ /* QueueItem uses Ant Design components with inline styles */
.item {
display: flex;
gap: 16px;
padding: 12px 16px;
align-items: flex-start;
border-radius: var(--md-sys-shape-xs);
background: var(--md-sys-color-surface-container-low);
}
.statusIcon {
font-size: 20px !important;
margin-top: 2px;
flex-shrink: 0;
}
.icon_queued { color: var(--md-sys-color-on-surface-variant); }
.icon_downloading { color: var(--md-sys-color-primary); }
.icon_completed { color: var(--md-sys-color-success); }
.icon_failed { color: var(--md-sys-color-error); }
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: baseline;
gap: 8px;
}
.filename {
flex: 1;
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.size {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
flex-shrink: 0;
}
.source {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
}
/* MD3 Linear Progress Indicator */
.progressTrack {
height: 4px;
border-radius: 2px;
background: var(--md-sys-color-surface-variant);
overflow: hidden;
margin-top: 4px;
}
.progressBar {
height: 100%;
border-radius: 2px;
background: var(--md-sys-color-primary);
transition: width .4s cubic-bezier(.4,0,.2,1);
}
.error {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-error);
}
.actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* MD3 Text Button */
.textBtn {
height: 32px;
padding: 0 8px;
border-radius: var(--md-sys-shape-full);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
position: relative;
overflow: hidden;
}
.textBtn::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.textBtn:hover::before { opacity: .08; }
.textBtn:active::before { opacity: .12; }
@@ -1,6 +1,12 @@
import { Button, Flex, Progress, Typography } from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
ScheduleOutlined,
} from '@ant-design/icons'
import type { QueueItem as IQueueItem } from '../../types' import type { QueueItem as IQueueItem } from '../../types'
import { formatBytes } from './utils' import { formatBytes } from './utils'
import s from './QueueItem.module.css'
interface Props { interface Props {
item: IQueueItem item: IQueueItem
@@ -8,11 +14,11 @@ interface Props {
onRemove: (id: string) => void onRemove: (id: string) => void
} }
const STATUS_ICON: Record<IQueueItem['status'], string> = { const STATUS_ICON: Record<IQueueItem['status'], React.ReactNode> = {
queued: 'schedule', queued: <ScheduleOutlined style={{ color: 'rgba(0,0,0,.45)' }} />,
downloading: 'downloading', downloading: <LoadingOutlined spin style={{ color: '#6750A4' }} />,
completed: 'check_circle', completed: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
failed: 'error', failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
} }
export default function QueueItem({ item, onRetry, onRemove }: Props) { export default function QueueItem({ item, onRetry, onRemove }: Props) {
@@ -20,40 +26,56 @@ export default function QueueItem({ item, onRetry, onRemove }: Props) {
? Math.round((item.downloadedBytes / item.sizeBytes) * 100) ? Math.round((item.downloadedBytes / item.sizeBytes) * 100)
: 0 : 0
const sizeLabel = item.status === 'completed'
? formatBytes(item.sizeBytes)
: `${formatBytes(item.downloadedBytes)} / ${formatBytes(item.sizeBytes)}`
return ( return (
<div className={`${s.item} ${s[`status_${item.status}`]}`}> <div style={{
<span className={`material-symbols-outlined ${s.statusIcon} ${s[`icon_${item.status}`]}`}> display: 'flex',
gap: 12,
padding: '10px 12px',
alignItems: 'flex-start',
borderRadius: 6,
background: '#fafafa',
border: '1px solid #f0f0f0',
}}>
<span style={{ fontSize: 18, marginTop: 2, display: 'flex', flexShrink: 0 }}>
{STATUS_ICON[item.status]} {STATUS_ICON[item.status]}
</span> </span>
<div className={s.content}> <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div className={s.row}> <Flex align="baseline" gap={8}>
<span className={s.filename}>{item.filename}</span> <Typography.Text ellipsis style={{ flex: 1, fontSize: 14 }}>{item.filename}</Typography.Text>
<span className={s.size}> <Typography.Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>{sizeLabel}</Typography.Text>
{item.status === 'completed' </Flex>
? formatBytes(item.sizeBytes)
: `${formatBytes(item.downloadedBytes)} / ${formatBytes(item.sizeBytes)}`}
</span>
</div>
<span className={s.source}>{item.source}</span> <Typography.Text type="secondary" ellipsis style={{ fontSize: 12 }}>{item.source}</Typography.Text>
{(item.status === 'downloading' || item.status === 'queued') && ( {(item.status === 'downloading' || item.status === 'queued') && (
<div className={s.progressTrack}> <Progress
<div className={s.progressBar} style={{ width: `${pct}%` }} /> percent={pct}
</div> size="small"
showInfo={false}
strokeColor="#6750A4"
style={{ margin: '2px 0 0' }}
/>
)} )}
{item.status === 'failed' && item.error && ( {item.status === 'failed' && item.error && (
<p className={s.error}>{item.error}</p> <Typography.Text type="danger" style={{ fontSize: 12 }}>{item.error}</Typography.Text>
)} )}
<div className={s.actions}> <Flex gap={4} style={{ marginTop: 2 }}>
{item.status === 'failed' && ( {item.status === 'failed' && (
<button className={s.textBtn} onClick={() => onRetry(item.id)}>Retry</button> <Button size="small" type="link" style={{ padding: 0, height: 'auto' }} onClick={() => onRetry(item.id)}>
Retry
</Button>
)} )}
<button className={s.textBtn} onClick={() => onRemove(item.id)}>Remove</button> <Button size="small" type="link" style={{ padding: 0, height: 'auto' }} onClick={() => onRemove(item.id)}>
</div> Remove
</Button>
</Flex>
</div> </div>
</div> </div>
) )
@@ -1,62 +1,101 @@
import { Badge, Layout, Tooltip } from 'antd'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import s from './Sidebar.module.css' import {
BookOutlined,
DownloadOutlined,
EditOutlined,
SettingOutlined,
ReadOutlined,
TeamOutlined,
} from '@ant-design/icons'
interface NavItem { const NAV = [
to: string { to: '/library', label: 'Library', icon: <BookOutlined />, badge: undefined as number | undefined },
label: string { to: '/authors', label: 'Authors', icon: <TeamOutlined />, badge: undefined },
icon: string { to: '/import', label: 'Import', icon: <DownloadOutlined />, badge: undefined },
iconFilled: string { to: '/metadata', label: 'Metadata', icon: <EditOutlined />, badge: undefined },
badge?: number
}
const NAV: NavItem[] = [
{ to: '/library', label: 'Library', icon: 'library_books', iconFilled: 'library_books', badge: 12 },
{ to: '/import', label: 'Import', icon: 'download', iconFilled: 'download', badge: 2 },
{ to: '/metadata', label: 'Metadata', icon: 'edit_note', iconFilled: 'edit_note' },
] ]
const rail: React.CSSProperties = {
width: 80,
minWidth: 80,
height: '100%',
background: '#F7F2FA',
borderRight: '1px solid #ede9f2',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '12px 0 16px',
overflow: 'hidden',
}
export default function Sidebar() { export default function Sidebar() {
return ( return (
<nav className={s.rail}> <Layout.Sider width={80} style={rail}>
<div className={s.brand}> {/* Brand */}
<span className={`material-symbols-outlined ${s.brandIcon}`}>auto_stories</span> <div style={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 4 }}>
<ReadOutlined style={{ fontSize: 26, color: '#6750A4' }} />
</div> </div>
<ul className={s.nav}> {/* Nav items */}
<nav style={{ flex: 1, width: '100%', padding: '0 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
{NAV.map(item => ( {NAV.map(item => (
<li key={item.to}> <NavLink key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
<NavLink
to={item.to}
className={({ isActive }) => `${s.link} ${isActive ? s.active : ''}`}
>
{({ isActive }) => ( {({ isActive }) => (
<> <Tooltip title={item.label} placement="right">
<div className={s.indicator}> <div style={{
{item.badge !== undefined && ( display: 'flex',
<span className={s.badge}>{item.badge}</span> flexDirection: 'column',
)} alignItems: 'center',
<span gap: 4,
className={`material-symbols-outlined ${s.icon} ${isActive ? s.iconFilled : ''}`} padding: '8px 4px',
> borderRadius: 8,
{isActive ? item.iconFilled : item.icon} background: isActive ? '#E8DEF8' : 'transparent',
cursor: 'pointer',
transition: 'background 150ms',
}}>
<Badge count={item.badge} size="small" offset={[4, -2]}>
<span style={{
fontSize: 20,
color: isActive ? '#21005D' : '#49454F',
display: 'flex',
alignItems: 'center',
}}>
{item.icon}
</span>
</Badge>
<span style={{
fontSize: 11,
lineHeight: 1,
color: isActive ? '#1C1B1F' : '#49454F',
fontWeight: isActive ? 600 : 400,
}}>
{item.label}
</span> </span>
</div> </div>
<span className={s.label}>{item.label}</span> </Tooltip>
</>
)} )}
</NavLink> </NavLink>
</li>
))} ))}
</ul>
<div className={s.footer}>
<button className={s.footerBtn}>
<div className={s.indicator}>
<span className="material-symbols-outlined">settings</span>
</div>
<span className={s.label}>Settings</span>
</button>
</div>
</nav> </nav>
{/* Footer */}
<div style={{ width: '100%', padding: '0 8px' }}>
<Tooltip title="Settings" placement="right">
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
padding: '8px 4px',
borderRadius: 8,
cursor: 'pointer',
}}>
<SettingOutlined style={{ fontSize: 20, color: '#49454F' }} />
<span style={{ fontSize: 11, color: '#49454F' }}>Settings</span>
</div>
</Tooltip>
</div>
</Layout.Sider>
) )
} }
+1 -89
View File
@@ -1,101 +1,13 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* MD3 Light Purple baseline */
--md-sys-color-primary: #6750A4;
--md-sys-color-on-primary: #FFFFFF;
--md-sys-color-primary-container: #EADDFF;
--md-sys-color-on-primary-container: #21005D;
--md-sys-color-secondary: #625B71;
--md-sys-color-on-secondary: #FFFFFF;
--md-sys-color-secondary-container: #E8DEF8;
--md-sys-color-on-secondary-container: #1D192B;
--md-sys-color-tertiary: #7D5260;
--md-sys-color-on-tertiary: #FFFFFF;
--md-sys-color-tertiary-container: #FFD8E4;
--md-sys-color-on-tertiary-container: #31111D;
--md-sys-color-error: #B3261E;
--md-sys-color-on-error: #FFFFFF;
--md-sys-color-error-container: #F9DEDC;
--md-sys-color-on-error-container: #410E0B;
--md-sys-color-background: #FEF7FF;
--md-sys-color-on-background: #1C1B1F;
--md-sys-color-surface: #FEF7FF;
--md-sys-color-on-surface: #1C1B1F;
--md-sys-color-surface-variant: #E7E0EC;
--md-sys-color-on-surface-variant: #49454F;
--md-sys-color-outline: #79747E;
--md-sys-color-outline-variant: #CAC4D0;
--md-sys-color-surface-container-lowest: #FFFFFF;
--md-sys-color-surface-container-low: #F7F2FA;
--md-sys-color-surface-container: #F3EDF7;
--md-sys-color-surface-container-high: #ECE6F0;
--md-sys-color-surface-container-highest:#E6E0E9;
--md-sys-color-inverse-surface: #313033;
--md-sys-color-inverse-on-surface: #F4EFF4;
--md-sys-color-inverse-primary: #D0BCFF;
--md-sys-color-success: #386A20;
--md-sys-color-success-container: #B7F397;
--md-sys-color-warning: #6E5E00;
--md-sys-color-warning-container: #FBE64B;
/* MD3 Shape */
--md-sys-shape-none: 0px;
--md-sys-shape-xs: 4px;
--md-sys-shape-sm: 8px;
--md-sys-shape-md: 12px;
--md-sys-shape-lg: 16px;
--md-sys-shape-xl: 28px;
--md-sys-shape-full: 50px;
/* MD3 Elevation */
--md-sys-elevation-1: 0px 1px 2px rgba(0,0,0,.3), 0px 1px 3px 1px rgba(0,0,0,.15);
--md-sys-elevation-2: 0px 1px 2px rgba(0,0,0,.3), 0px 2px 6px 2px rgba(0,0,0,.15);
--md-sys-elevation-3: 0px 4px 8px 3px rgba(0,0,0,.15), 0px 1px 3px rgba(0,0,0,.3);
/* Typography */
--md-sys-typescale-body-large: 400 1rem/1.5rem 'Roboto', sans-serif;
--md-sys-typescale-body-medium: 400 .875rem/1.25rem 'Roboto', sans-serif;
--md-sys-typescale-body-small: 400 .75rem/1rem 'Roboto', sans-serif;
--md-sys-typescale-label-large: 500 .875rem/1.25rem 'Roboto', sans-serif;
--md-sys-typescale-label-medium:500 .75rem/1rem 'Roboto', sans-serif;
--md-sys-typescale-label-small: 500 .6875rem/1rem 'Roboto', sans-serif;
--md-sys-typescale-title-large: 400 1.375rem/1.75rem 'Roboto', sans-serif;
--md-sys-typescale-title-medium:500 1rem/1.5rem 'Roboto', sans-serif;
--md-sys-typescale-title-small: 500 .875rem/1.25rem 'Roboto', sans-serif;
--md-sys-typescale-headline-small: 400 1.5rem/2rem 'Roboto', sans-serif;
--nav-rail-w: 80px;
}
html, body, #root { html, body, #root {
height: 100%; height: 100%;
background: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
font: var(--md-sys-typescale-body-medium);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: 24px;
line-height: 1;
user-select: none;
}
button { cursor: pointer; font: inherit; border: none; background: none; color: inherit; }
a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; }
input, textarea, select {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
outline: none;
}
::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--md-sys-color-outline-variant); background: rgba(0, 0, 0, 0.15);
border-radius: 2px; border-radius: 2px;
} }
@@ -0,0 +1,72 @@
.page {
height: 100%;
overflow-y: auto;
}
.content {
max-width: 960px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.header {
margin-bottom: 36px;
padding-bottom: 32px;
border-bottom: 1px solid #f0f0f0;
}
.avatar {
width: 100px;
height: 100px;
min-width: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,.15);
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarInitials {
font-size: 2rem;
font-weight: 400;
color: rgba(255,255,255,.9);
letter-spacing: .02em;
user-select: none;
}
.authorInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 6px;
}
.section {
margin-bottom: 40px;
}
.seriesGroup {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.seriesGroup:first-of-type {
border-top: none;
padding-top: 12px;
}
.bookGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, Flex, Skeleton, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import type { AuthorDetail as IAuthorDetail, Book } from '../../types'
import { fetchAuthor } from '../../api/authors'
import BookCard from '../../components/BookCard/BookCard'
import s from './AuthorDetail.module.css'
export default function AuthorDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [author, setAuthor] = useState<IAuthorDetail | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) return
setLoading(true)
fetchAuthor(Number(id))
.then(setAuthor)
.catch(() => setAuthor(null))
.finally(() => setLoading(false))
}, [id])
// Group books: series → Map<seriesName, Book[]>, standalone
const seriesGroups = useMemo(() => {
if (!author) return []
const map = new Map<string, Book[]>()
for (const book of author.books) {
if (book.series) {
const arr = map.get(book.series.name) ?? []
arr.push(book)
map.set(book.series.name, arr)
}
}
for (const [, books] of map)
books.sort((a, b) => a.series!.position - b.series!.position)
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [author])
const standalone = useMemo(() =>
author?.books
.filter(b => !b.series)
.sort((a, b) => a.title.localeCompare(b.title))
?? [], [author])
const initials = author?.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() ?? ''
const color = avatarColor(author?.id ?? 0)
return (
<div className={s.page}>
<div className={s.content}>
{/* Back */}
<div style={{ marginBottom: 24 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/authors')}>
Authors
</Button>
</div>
{loading && <AuthorSkeleton />}
{!loading && !author && (
<Flex justify="center" align="center" style={{ padding: '80px 0' }}>
<Typography.Text type="secondary">Author not found.</Typography.Text>
</Flex>
)}
{!loading && author && (
<>
{/* Author header */}
<Flex gap={28} align="flex-start" className={s.header}>
<div className={s.avatar} style={{ background: color }}>
{author.imageUrl
? <img src={author.imageUrl} alt={author.name} className={s.avatarImg} />
: <span className={s.avatarInitials}>{initials}</span>
}
</div>
<div className={s.authorInfo}>
<Typography.Title level={2} style={{ margin: 0 }}>{author.name}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{author.books.length} {author.books.length === 1 ? 'book' : 'books'}
{author.bornYear ? ` · Born ${author.bornYear}` : ''}
</Typography.Text>
{author.bio && (
<Typography.Paragraph style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,.65)', marginTop: 10, marginBottom: 0, maxWidth: 600 }}>
{author.bio}
</Typography.Paragraph>
)}
</div>
</Flex>
{/* Collections / series */}
{seriesGroups.length > 0 && (
<section className={s.section}>
<Typography.Title level={4} style={{ marginBottom: 0 }}>Collections</Typography.Title>
{seriesGroups.map(([seriesName, books]) => (
<div key={seriesName} className={s.seriesGroup}>
<Flex align="baseline" gap={8} style={{ marginBottom: 10 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{seriesName}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{books.length} {books.length === 1 ? 'book' : 'books'}
</Typography.Text>
</Flex>
<div className={s.bookGrid}>
{books.map(book => (
<BookCard
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
</div>
</div>
))}
</section>
)}
{/* Standalone books */}
{standalone.length > 0 && (
<section className={s.section}>
<Flex align="baseline" gap={8} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Standalone</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{standalone.length} {standalone.length === 1 ? 'book' : 'books'}
</Typography.Text>
</Flex>
<div className={s.bookGrid}>
{standalone.map(book => (
<BookCard
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
</div>
</section>
)}
</>
)}
</div>
</div>
)
}
function avatarColor(id: number): string {
const palette = ['#6750A4', '#7B5EA7', '#5E35B1', '#4527A0', '#9575CD', '#7E57C2']
return palette[id % palette.length]
}
function AuthorSkeleton() {
return (
<Flex gap={28} align="flex-start" style={{ padding: '8px 0 32px' }}>
<Skeleton.Avatar active size={100} />
<div style={{ flex: 1 }}>
<Skeleton active paragraph={{ rows: 3 }} />
</div>
</Flex>
)
}
@@ -0,0 +1,63 @@
.page {
height: 100%;
overflow-y: auto;
}
.content {
max-width: 960px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 16px;
background: #fff;
border-radius: 10px;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: box-shadow 200ms, border-color 150ms;
}
.card:hover {
box-shadow: 0 4px 14px rgba(0,0,0,.08);
border-color: #d9d9d9;
}
.avatar {
width: 60px;
height: 60px;
min-width: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarInitials {
font-size: 1.25rem;
font-weight: 500;
color: rgba(255,255,255,.9);
letter-spacing: .02em;
user-select: none;
}
.cardBody {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Flex, Input, Typography } from 'antd'
import { UserOutlined } from '@ant-design/icons'
import type { AuthorSummary } from '../../types'
import { fetchAuthors } from '../../api/authors'
import s from './Authors.module.css'
export default function Authors() {
const [authors, setAuthors] = useState<AuthorSummary[]>([])
const [query, setQuery] = useState('')
const navigate = useNavigate()
useEffect(() => { fetchAuthors().then(setAuthors) }, [])
const filtered = useMemo(() =>
authors.filter(a =>
!query || a.name.toLowerCase().includes(query.toLowerCase())
), [authors, query])
return (
<div className={s.page}>
<div className={s.content}>
<Flex align="center" gap={16} style={{ marginBottom: 20 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Authors</Typography.Title>
<Input
placeholder="Search authors…"
value={query}
onChange={e => setQuery(e.target.value)}
allowClear
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary" style={{ marginLeft: 'auto', fontSize: 13 }}>
{filtered.length} authors
</Typography.Text>
</Flex>
<div className={s.grid}>
{filtered.map(author => (
<AuthorCard
key={author.id}
author={author}
onClick={() => navigate(`/authors/${author.id}`)}
/>
))}
{filtered.length === 0 && (
<Typography.Text type="secondary" style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '48px 0', display: 'block' }}>
No authors found.
</Typography.Text>
)}
</div>
</div>
</div>
)
}
function avatarColor(id: number): string {
const palette = ['#6750A4', '#7B5EA7', '#5E35B1', '#4527A0', '#9575CD', '#7E57C2']
return palette[id % palette.length]
}
function AuthorCard({ author, onClick }: { author: AuthorSummary; onClick: () => void }) {
const initials = author.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
const color = avatarColor(author.id)
return (
<div className={s.card} onClick={onClick}>
<div className={s.avatar} style={{ background: color }}>
{author.imageUrl
? <img src={author.imageUrl} alt={author.name} className={s.avatarImg} />
: author.imageUrl === undefined
? <UserOutlined style={{ fontSize: 28, color: 'rgba(255,255,255,.7)' }} />
: <span className={s.avatarInitials}>{initials}</span>
}
</div>
<div className={s.cardBody}>
<Typography.Text strong style={{ fontSize: 14, display: 'block' }}>
{author.name}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{author.bookCount} {author.bookCount === 1 ? 'book' : 'books'}
{author.bornYear ? ` · b. ${author.bornYear}` : ''}
</Typography.Text>
{author.bio && (
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ fontSize: 12, color: 'rgba(0,0,0,.55)', marginTop: 6, marginBottom: 0 }}
>
{author.bio}
</Typography.Paragraph>
)}
</div>
</div>
)
}
@@ -0,0 +1,81 @@
.page {
height: 100%;
overflow-y: auto;
}
.content {
max-width: 860px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.header {
margin-bottom: 28px;
}
.hero {
margin-bottom: 32px;
}
.cover {
width: 180px;
min-width: 180px;
aspect-ratio: 2 / 3;
border-radius: 10px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0,0,0,.18);
}
.coverImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.coverInitials {
font-size: 2.5rem;
font-weight: 400;
color: rgba(255,255,255,.3);
letter-spacing: .04em;
user-select: none;
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
}
.stats {
display: flex;
flex-direction: column;
gap: 10px;
}
.section {
border-top: 1px solid #f0f0f0;
padding-top: 24px;
margin-bottom: 24px;
}
.editionList {
display: flex;
flex-direction: column;
gap: 2px;
}
.editionRow {
padding: 8px 12px;
border-radius: 6px;
transition: background 150ms;
}
.editionRow:hover {
background: rgba(0,0,0,.03);
}
@@ -0,0 +1,329 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, Divider, Flex, Popconfirm, Skeleton, Tag, Typography } from 'antd'
import {
ArrowLeftOutlined,
AudioOutlined,
BankOutlined,
CalendarOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
LinkOutlined,
ReadOutlined,
TabletOutlined,
} from '@ant-design/icons'
import type { Book, BookFile, Edition, ReadingFormat } from '../../types'
import { fetchBook } from '../../api/books'
import { assignFile, deleteFile, fetchBookFiles } from '../../api/files'
import s from './BookDetail.module.css'
export default function BookDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [book, setBook] = useState<Book | null>(null)
const [loading, setLoading] = useState(true)
const [files, setFiles] = useState<BookFile[]>([])
useEffect(() => {
if (!id) return
setLoading(true)
fetchBook(Number(id))
.then(b => {
setBook(b)
return fetchBookFiles(b.id)
})
.then(setFiles)
.catch(() => setBook(null))
.finally(() => setLoading(false))
}, [id])
function handleUnlink(fileId: number) {
assignFile(fileId, null, null).then(updated =>
setFiles(fs => fs.map(f => f.id === fileId ? updated : f))
)
}
function handleDeleteFile(fileId: number) {
deleteFile(fileId).then(() => setFiles(fs => fs.filter(f => f.id !== fileId)))
}
return (
<div className={s.page}>
<div className={s.content}>
{/* Back + actions */}
<Flex align="center" gap={12} className={s.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/library')}
>
Library
</Button>
<div style={{ flex: 1 }} />
{book && (
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
>
Edit Metadata
</Button>
)}
</Flex>
{loading && <BookSkeleton />}
{!loading && !book && (
<Flex justify="center" align="center" style={{ padding: '80px 0' }}>
<Typography.Text type="secondary">Book not found.</Typography.Text>
</Flex>
)}
{!loading && book && (
<>
{/* Hero */}
<Flex gap={36} align="flex-start" className={s.hero}>
<div className={s.cover} style={{ background: book.color }}>
{book.coverUrl
? <img src={book.coverUrl} alt={book.title} className={s.coverImg} />
: <span className={s.coverInitials}>
{book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()}
</span>
}
</div>
<div className={s.info}>
<Typography.Title level={2} style={{ margin: 0, lineHeight: 1.2 }}>
{book.title}
</Typography.Title>
<Typography.Text style={{ fontSize: 16, color: 'rgba(0,0,0,.65)' }}>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
{book.series && (
<Typography.Text style={{ fontSize: 14, color: '#6750A4' }}>
{book.series.name} · Book {book.series.position}
{book.series.arc ? ` · ${book.series.arc}` : ''}
</Typography.Text>
)}
<Divider style={{ margin: '12px 0' }} />
<div className={s.stats}>
{book.year && <StatRow icon={<CalendarOutlined />} label="Year" value={String(book.year)} />}
{book.pages && <StatRow icon={<FileTextOutlined />} label="Pages" value={String(book.pages)} />}
{book.publisher && <StatRow icon={<BankOutlined />} label="Publisher" value={book.publisher} />}
</div>
{book.formats.length > 0 && (
<Flex gap={6} wrap="wrap">
{book.formats.map(f => (
<Tag key={f}>{f.toUpperCase()}</Tag>
))}
</Flex>
)}
{book.genres.length > 0 && (
<Flex gap={6} wrap="wrap">
{book.genres.map(g => (
<Tag key={g} color="purple">{g}</Tag>
))}
</Flex>
)}
</div>
</Flex>
{/* Description */}
{book.description && (
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>About</Typography.Title>
<Typography.Paragraph style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,.75)', marginBottom: 0 }}>
{book.description}
</Typography.Paragraph>
</section>
)}
{/* Editions */}
{(() => {
const filtered = book.editions.filter(ed =>
!ed.language || ed.language === 'English' || ed.language === 'Latvian'
)
return filtered.length > 0 ? (
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
Editions from Hardcover ({filtered.length})
</Typography.Title>
<div className={s.editionList}>
{filtered.map(ed => (
<EditionRow key={ed.id} edition={ed} />
))}
</div>
</section>
) : null
})()}
{/* Files */}
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
Files {files.length > 0 && `(${files.length})`}
</Typography.Title>
{files.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
No files linked to this book.
</Typography.Text>
) : (
<div className={s.editionList}>
{files.map(f => (
<FileRow
key={f.id}
file={f}
onUnlink={handleUnlink}
onDelete={handleDeleteFile}
/>
))}
</div>
)}
</section>
</>
)}
</div>
</div>
)
}
function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Flex align="flex-start" gap={10}>
<span style={{ color: 'rgba(0,0,0,.4)', fontSize: 16, marginTop: 2, display: 'flex' }}>{icon}</span>
<div>
<div style={{ fontSize: 11, color: 'rgba(0,0,0,.4)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
{label}
</div>
<div style={{ fontSize: 14, color: 'rgba(0,0,0,.85)' }}>{value}</div>
</div>
</Flex>
)
}
const FORMAT_ICON: Record<ReadingFormat, React.ReactNode> = {
Physical: <ReadOutlined />,
Audio: <AudioOutlined />,
Both: <ReadOutlined />,
Ebook: <TabletOutlined />,
}
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] : <ReadOutlined />
const label = edition.editionFormat ?? edition.readingFormat ?? null
const meta: string[] = []
if (edition.publisher) meta.push(edition.publisher)
if (edition.releaseYear) meta.push(String(edition.releaseYear))
if (edition.pages) meta.push(`${edition.pages} pp`)
if (edition.audioSeconds) meta.push(formatAudio(edition.audioSeconds))
if (edition.language && edition.language !== 'English') meta.push(edition.language) // shows "Latvian" etc.
return (
<Flex gap={12} align="flex-start" className={s.editionRow}>
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 18, display: 'flex', marginTop: 2 }}>{icon}</span>
<div>
{label && (
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>{label}</Typography.Text>
)}
{edition.isbn && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, fontFamily: 'monospace', display: 'block' }}
>
ISBN: {edition.isbn}
</Typography.Text>
)}
{!edition.isbn && edition.asin && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, fontFamily: 'monospace', display: 'block' }}
>
ASIN: {edition.asin}
</Typography.Text>
)}
{meta.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{meta.join(' · ')}
</Typography.Text>
)}
</div>
</Flex>
)
}
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(0)} KB`
return `${bytes} B`
}
function FileRow({
file,
onUnlink,
onDelete,
}: {
file: BookFile
onUnlink: (id: number) => void
onDelete: (id: number) => void
}) {
return (
<Flex gap={12} align="center" className={s.editionRow}>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
{file.format}
</Tag>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text
ellipsis
style={{ display: 'block', fontSize: 13, fontWeight: 500 }}
>
{file.filename}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatBytes(file.sizeBytes)}
</Typography.Text>
</div>
<Flex gap={4}>
<Button
size="small"
icon={<LinkOutlined />}
onClick={() => onUnlink(file.id)}
title="Unlink from book"
/>
<Popconfirm
title="Remove this file record?"
description="The file on disk is not deleted."
onConfirm={() => onDelete(file.id)}
okText="Remove"
okType="danger"
>
<Button size="small" icon={<DeleteOutlined />} danger title="Remove record" />
</Popconfirm>
</Flex>
</Flex>
)
}
function BookSkeleton() {
return (
<Flex gap={36} align="flex-start" style={{ padding: '24px 0' }}>
<Skeleton.Image active style={{ width: 180, height: 270, borderRadius: 8 }} />
<div style={{ flex: 1 }}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
</Flex>
)
}
@@ -1,213 +1 @@
.page { /* Import page uses Ant Design components with inline styles */
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
height: 100%;
overflow: hidden;
}
.col {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 24px;
gap: 24px;
}
.col + .col {
border-left: 1px solid var(--md-sys-color-outline-variant);
}
/* MD3 Section heading */
.heading {
font: var(--md-sys-typescale-title-medium);
color: var(--md-sys-color-on-surface);
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.headingBadge {
height: 20px;
min-width: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
font: var(--md-sys-typescale-label-small);
display: flex;
align-items: center;
justify-content: center;
}
/* MD3 Drop Zone — outlined card variant */
.dropzone {
border: 2px dashed var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-md);
padding: 40px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 200ms, background 200ms;
position: relative;
overflow: hidden;
}
.dropzone::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.dropzone:hover::before, .dropzone.dropping::before { opacity: .05; }
.dropzone.dropping { border-color: var(--md-sys-color-primary); }
.dropIcon {
color: var(--md-sys-color-on-surface-variant);
font-size: 40px !important;
position: relative;
}
.dropText {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
position: relative;
}
.dropHint {
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface-variant);
position: relative;
}
/* Sources list */
.sourceList {
display: flex;
flex-direction: column;
}
.sourceItem {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.sourceItem:first-child {
border-top: 1px solid var(--md-sys-color-outline-variant);
}
.sourceLeading {
width: 40px;
height: 40px;
border-radius: var(--md-sys-shape-full);
background: var(--md-sys-color-secondary-container);
display: flex;
align-items: center;
justify-content: center;
color: var(--md-sys-color-on-secondary-container);
flex-shrink: 0;
}
.sourceInfo {
flex: 1;
min-width: 0;
}
.sourceName {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
display: block;
}
.sourcePath {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
/* MD3 Switch */
.switch {
position: relative;
width: 52px;
height: 32px;
border-radius: 16px;
background: var(--md-sys-color-surface-variant);
border: 2px solid var(--md-sys-color-outline);
transition: background 200ms, border-color 200ms;
flex-shrink: 0;
}
.switch.switchOn {
background: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.switchThumb {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--md-sys-color-outline);
top: 50%;
transform: translateY(-50%);
left: 6px;
transition: left 200ms, width 200ms, background 200ms;
}
.switch.switchOn .switchThumb {
left: 26px;
width: 24px;
height: 24px;
top: 50%;
transform: translate(0, -50%);
left: 22px;
background: var(--md-sys-color-on-primary);
}
/* MD3 Text Button */
.addBtn {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px;
border-radius: var(--md-sys-shape-full);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
position: relative;
overflow: hidden;
align-self: flex-start;
}
.addBtn::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.addBtn:hover::before { opacity: .08; }
.queueList {
display: flex;
flex-direction: column;
gap: 2px;
}
.empty {
padding: 48px 0;
text-align: center;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-large);
}
+151 -73
View File
@@ -1,31 +1,43 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import type { QueueItem as IQueueItem, ImportSource } from '../../types' import { Badge, Button, Flex, Switch, Tag, Typography, Upload } from 'antd'
import {
BookOutlined,
FolderOutlined,
GlobalOutlined,
PlusOutlined,
ScanOutlined,
UploadOutlined,
WifiOutlined,
} from '@ant-design/icons'
import type { BookFile, QueueItem as IQueueItem, ImportSource } from '../../types'
import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../../api/importQueue' import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../../api/importQueue'
import { fetchUnmatchedFiles, triggerScan } from '../../api/files'
import QueueItem from '../../components/QueueItem/QueueItem' import QueueItem from '../../components/QueueItem/QueueItem'
import s from './Import.module.css'
const SOURCE_ICONS: Record<string, string> = { const SOURCE_ICONS: Record<string, React.ReactNode> = {
folder: 'folder', folder: <FolderOutlined />,
calibre: 'auto_stories', calibre: <BookOutlined />,
opds: 'rss_feed', opds: <WifiOutlined />,
url: 'language', url: <GlobalOutlined />,
} }
export default function Import() { export default function Import() {
const [queue, setQueue] = useState<IQueueItem[]>([]) const [queue, setQueue] = useState<IQueueItem[]>([])
const [sources, setSources] = useState<ImportSource[]>([]) const [sources, setSources] = useState<ImportSource[]>([])
const [dragging, setDragging] = useState(false) const [unmatched, setUnmatched] = useState<BookFile[]>([])
const inputRef = useRef<HTMLInputElement>(null) const [scanning, setScanning] = useState(false)
useEffect(() => { useEffect(() => {
fetchQueue().then(setQueue) fetchQueue().then(setQueue)
fetchSources().then(setSources) fetchSources().then(setSources)
fetchUnmatchedFiles().then(setUnmatched)
}, []) }, [])
function handleDrop(e: React.DragEvent) { function handleScan() {
e.preventDefault() setScanning(true)
setDragging(false) triggerScan()
console.log('dropped files:', Array.from(e.dataTransfer.files).map(f => f.name)) .then(() => fetchUnmatchedFiles().then(setUnmatched))
.finally(() => setScanning(false))
} }
function handleRetry(id: string) { function handleRetry(id: string) {
@@ -52,96 +64,162 @@ export default function Import() {
const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed') const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed')
return ( return (
<div className={s.page}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', height: '100%', overflow: 'hidden' }}>
{/* Left column */} {/* Left column */}
<div className={s.col}> <div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', padding: 24, gap: 24 }}>
<section> <section>
<h2 className={s.heading}>Drop files</h2> <Typography.Title level={5} style={{ marginBottom: 12 }}>Drop files</Typography.Title>
<div <Upload.Dragger
className={`${s.dropzone} ${dragging ? s.dropping : ''}`}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
>
<span className={`material-symbols-outlined ${s.dropIcon}`}>upload_file</span>
<span className={s.dropText}>Drop EPUB, MOBI, PDF files here</span>
<span className={s.dropHint}>or click to browse</span>
<input
ref={inputRef}
type="file"
accept=".epub,.mobi,.pdf,.cbz,.cbr"
multiple multiple
style={{ display: 'none' }} accept=".epub,.mobi,.pdf,.cbz,.cbr"
onChange={e => console.log('files:', e.target.files)} showUploadList={false}
/> beforeUpload={file => {
console.log('file:', file.name)
return false
}}
style={{ padding: '8px 0' }}
>
<div style={{ padding: '16px 0' }}>
<UploadOutlined style={{ fontSize: 40, color: 'rgba(0,0,0,.25)', display: 'block', marginBottom: 12 }} />
<Typography.Text>Drop EPUB, MOBI, PDF files here</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 13 }}>or click to browse</Typography.Text>
</div> </div>
</Upload.Dragger>
</section> </section>
<section> <section>
<h2 className={s.heading}>Sources</h2> <Typography.Title level={5} style={{ marginBottom: 8 }}>Sources</Typography.Title>
<ul className={s.sourceList}> <div style={{ borderTop: '1px solid #f0f0f0' }}>
{sources.map(src => ( {sources.map(src => (
<li key={src.id} className={s.sourceItem}> <div key={src.id} style={{
<div className={s.sourceLeading}> display: 'flex',
<span className="material-symbols-outlined"> alignItems: 'center',
{SOURCE_ICONS[src.type] ?? 'language'} gap: 12,
</span> padding: '12px 0',
borderBottom: '1px solid #f0f0f0',
}}>
<div style={{
width: 36,
height: 36,
borderRadius: '50%',
background: '#EDE7F6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#6750A4',
flexShrink: 0,
}}>
{SOURCE_ICONS[src.type] ?? <GlobalOutlined />}
</div> </div>
<div className={s.sourceInfo}> <div style={{ flex: 1, minWidth: 0 }}>
<span className={s.sourceName}>{src.name}</span> <Typography.Text style={{ display: 'block', fontSize: 14 }}>{src.name}</Typography.Text>
<span className={s.sourcePath}>{src.path}</span> <Typography.Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>
{src.path}
</Typography.Text>
</div> </div>
<button <Switch
className={`${s.switch} ${src.enabled ? s.switchOn : ''}`} checked={src.enabled}
onClick={() => toggleSource(src.id)} onChange={() => toggleSource(src.id)}
aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`} aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`}
> size="small"
<span className={s.switchThumb} /> />
</button> </div>
</li>
))} ))}
</ul> </div>
<button className={s.addBtn}> <Button
<span className="material-symbols-outlined">add</span> type="dashed"
icon={<PlusOutlined />}
style={{ marginTop: 12 }}
onClick={() => {}}
>
Add source Add source
</button> </Button>
</section>
<section>
<Flex align="center" gap={8} style={{ marginBottom: 8 }}>
<Typography.Title level={5} style={{ margin: 0 }}>Scan</Typography.Title>
</Flex>
<Button
icon={<ScanOutlined />}
loading={scanning}
onClick={handleScan}
>
Scan sources now
</Button>
</section> </section>
</div> </div>
{/* Right column */} {/* Right column */}
<div className={s.col}> <div style={{
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
padding: 24,
gap: 24,
borderLeft: '1px solid #f0f0f0',
}}>
{active.length > 0 && ( {active.length > 0 && (
<section> <section>
<h2 className={s.heading}> <Flex align="center" gap={8} style={{ marginBottom: 12 }}>
Downloading <Typography.Title level={5} style={{ margin: 0 }}>Downloading</Typography.Title>
<span className={s.headingBadge}>{active.length}</span> <Badge count={active.length} color="#6750A4" />
</h2> </Flex>
<ul className={s.queueList}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{active.map(item => ( {active.map(item => (
<li key={item.id}> <QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
</li>
))} ))}
</ul> </div>
</section> </section>
)} )}
{finished.length > 0 && ( {finished.length > 0 && (
<section> <section>
<h2 className={s.heading}>History</h2> <Typography.Title level={5} style={{ marginBottom: 12 }}>History</Typography.Title>
<ul className={s.queueList}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{finished.map(item => ( {finished.map(item => (
<li key={item.id}> <QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
</li>
))} ))}
</ul> </div>
</section> </section>
)} )}
{queue.length === 0 && ( {queue.length === 0 && unmatched.length === 0 && (
<div className={s.empty}>No recent activity.</div> <Flex align="center" justify="center" style={{ flex: 1, minHeight: 120 }}>
<Typography.Text type="secondary">No recent activity.</Typography.Text>
</Flex>
)}
{unmatched.length > 0 && (
<section>
<Flex align="center" gap={8} style={{ marginBottom: 12 }}>
<Typography.Title level={5} style={{ margin: 0 }}>Unmatched Files</Typography.Title>
<Badge count={unmatched.length} color="#6750A4" />
</Flex>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{unmatched.map(f => (
<Flex
key={f.id}
align="center"
gap={10}
style={{
padding: '8px 12px',
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
}}
>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
{f.format}
</Tag>
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
{f.filename}
</Typography.Text>
</Flex>
))}
</div>
</section>
)} )}
</div> </div>
</div> </div>
@@ -12,174 +12,44 @@
overflow: hidden; overflow: hidden;
} }
/* MD3 Search bar */ .topBar {
.searchWrap { padding: 14px 16px 10px;
padding: 16px 16px 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.search {
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 16px;
background: var(--md-sys-color-surface-container-high);
border-radius: var(--md-sys-shape-full);
width: 100%;
max-width: 560px;
}
.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);
}
/* MD3 Filter Chips bar */
.chips { .chips {
display: flex; padding: 0 16px 10px;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 12px;
align-items: center;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
} }
.divider {
width: 1px;
height: 20px;
background: var(--md-sys-color-outline-variant);
}
/* MD3 Filter Chip */
.chip {
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);
background: transparent;
display: flex;
align-items: center;
gap: 4px;
position: relative;
overflow: hidden;
transition: background 200ms;
cursor: pointer;
}
.chip::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-on-surface);
opacity: 0;
transition: opacity 200ms;
}
.chip:hover::before { opacity: .08; }
.chipActive {
background: var(--md-sys-color-secondary-container);
border-color: transparent;
color: var(--md-sys-color-on-secondary-container);
}
.chipActive::before { background: var(--md-sys-color-on-secondary-container); }
.countBar { .countBar {
display: flex; padding: 0 16px 6px;
align-items: center;
gap: 12px;
padding: 8px 16px 4px;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid #f0f0f0;
} }
.count {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
}
/* MD3 Text Button */
.clearBtn {
height: 28px;
padding: 0 8px;
border-radius: var(--md-sys-shape-full);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
position: relative;
overflow: hidden;
font-size: .75rem !important;
}
.clearBtn::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.clearBtn:hover::before { opacity: .08; }
.grid { .grid {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px 16px 24px; padding: 12px 16px 80px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px; gap: 10px;
align-content: start; align-content: start;
} }
.list {
flex: 1;
overflow-y: auto;
padding: 8px 0 80px;
display: flex;
flex-direction: column;
}
.empty { .empty {
grid-column: 1 / -1; grid-column: 1 / -1;
padding: 48px 0; padding: 48px 0;
text-align: center; text-align: center;
color: var(--md-sys-color-on-surface-variant); color: rgba(0, 0, 0, 0.45);
font: var(--md-sys-typescale-body-large); font-size: 14px;
} }
/* MD3 FAB */
.fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: var(--md-sys-shape-lg);
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--md-sys-elevation-3);
z-index: 10;
position: fixed;
overflow: hidden;
transition: box-shadow 200ms;
}
.fab::before {
content: '';
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity 200ms;
}
.fab:hover { box-shadow: var(--md-sys-elevation-4); }
.fab:hover::before { opacity: .08; }
.fab:active::before { opacity: .12; }
+116 -48
View File
@@ -1,22 +1,40 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Divider, Flex, FloatButton, Input, Segmented, Select, Space, Tag, Typography } from 'antd'
import { AppstoreOutlined, CloseOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons'
import type { Book, Format } from '../../types' import type { Book, Format } from '../../types'
import { fetchBooks } from '../../api/books' import { fetchBooks } from '../../api/books'
import { filterBooks } from './utils' import { filterBooks } from './utils'
import BookCard from '../../components/BookCard/BookCard' import BookCard from '../../components/BookCard/BookCard'
import DetailPanel from '../../components/DetailPanel/DetailPanel' import BookRow from '../../components/BookRow/BookRow'
import AddBookDialog from '../../components/AddBookDialog/AddBookDialog' import AddBookDialog from '../../components/AddBookDialog/AddBookDialog'
import s from './Library.module.css' import s from './Library.module.css'
type ViewMode = 'grid' | 'list'
type SortBy = 'title' | 'author' | 'year'
const ALL_FORMATS: Format[] = ['epub', 'mobi', 'pdf', 'cbz', 'cbr'] const ALL_FORMATS: Format[] = ['epub', 'mobi', 'pdf', 'cbz', 'cbr']
function sortBooks(books: Book[], by: SortBy): Book[] {
const arr = [...books]
if (by === 'author')
return arr.sort((a, b) =>
(a.authors[0]?.name ?? '').localeCompare(b.authors[0]?.name ?? ''))
if (by === 'year')
return arr.sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
return arr.sort((a, b) => a.title.localeCompare(b.title))
}
export default function Library() { export default function Library() {
const [books, setBooks] = useState<Book[]>([]) const [books, setBooks] = useState<Book[]>([])
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [genres, setGenres] = useState<string[]>([]) const [genres, setGenres] = useState<string[]>([])
const [formats, setFormats] = useState<Format[]>([]) const [formats, setFormats] = useState<Format[]>([])
const [selected, setSelected] = useState<Book | null>(null)
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [sortBy, setSortBy] = useState<SortBy>('title')
const navigate = useNavigate()
const refreshBooks = () => fetchBooks().then(setBooks) const refreshBooks = () => fetchBooks().then(setBooks)
useEffect(() => { refreshBooks() }, []) useEffect(() => { refreshBooks() }, [])
@@ -26,81 +44,131 @@ export default function Library() {
const activeFormats = useMemo(() => const activeFormats = useMemo(() =>
ALL_FORMATS.filter(f => books.some(b => b.formats.includes(f))), [books]) ALL_FORMATS.filter(f => books.some(b => b.formats.includes(f))), [books])
const filtered = useMemo(() => filterBooks(books, query, genres, formats), [books, query, genres, formats]) const filtered = useMemo(
() => filterBooks(books, query, genres, formats),
[books, query, genres, formats])
const sorted = useMemo(() => sortBooks(filtered, sortBy), [filtered, sortBy])
const toggleGenre = (g: string) => setGenres(p => p.includes(g) ? p.filter(x => x !== g) : [...p, g]) const toggleGenre = (g: string) => setGenres(p => p.includes(g) ? p.filter(x => x !== g) : [...p, g])
const toggleFormat = (f: Format) => setFormats(p => p.includes(f) ? p.filter(x => x !== f) : [...p, f]) const toggleFormat = (f: Format) => setFormats(p => p.includes(f) ? p.filter(x => x !== f) : [...p, f])
const hasFilter = genres.length > 0 || formats.length > 0 || Boolean(query) const hasFilter = genres.length > 0 || formats.length > 0 || Boolean(query)
return ( return (
<div className={s.layout}>
<div className={s.main}> <div className={s.main}>
<div className={s.searchWrap}>
<div className={s.search}> {/* ── Top bar ── */}
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span> <Flex gap={12} align="center" className={s.topBar}>
<input <Input
className={s.searchInput} prefix={<span style={{ color: 'rgba(0,0,0,.45)' }}></span>}
type="search"
placeholder="Search books and authors…" placeholder="Search books and authors…"
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
allowClear
style={{ flex: 1, maxWidth: 520 }}
/> />
</div> <Space style={{ marginLeft: 'auto' }} size={8}>
</div> <Select
value={sortBy}
onChange={v => setSortBy(v)}
style={{ width: 110 }}
options={[
{ value: 'title', label: 'Title' },
{ value: 'author', label: 'Author' },
{ value: 'year', label: 'Year' },
]}
/>
<Segmented
value={viewMode}
onChange={v => setViewMode(v as ViewMode)}
options={[
{ value: 'grid', icon: <AppstoreOutlined /> },
{ value: 'list', icon: <UnorderedListOutlined /> },
]}
/>
</Space>
</Flex>
<div className={s.chips}> {/* ── Filter chips ── */}
{(allGenres.length > 0 || activeFormats.length > 0) && (
<Flex gap={6} wrap="wrap" align="center" className={s.chips}>
{allGenres.map(g => ( {allGenres.map(g => (
<button <Tag.CheckableTag
key={g} key={g}
className={`${s.chip} ${genres.includes(g) ? s.chipActive : ''}`} checked={genres.includes(g)}
onClick={() => toggleGenre(g)} onChange={() => toggleGenre(g)}
> >
{genres.includes(g) && <span className="material-symbols-outlined" style={{fontSize:16}}>done</span>}
{g} {g}
</button> </Tag.CheckableTag>
))} ))}
{activeFormats.length > 0 && <span className={s.divider} />} {activeFormats.length > 0 && allGenres.length > 0 && (
{activeFormats.map(f => ( <Divider type="vertical" style={{ height: 18, margin: 'auto 2px', borderColor: '#d9d9d9' }} />
<button )}
key={f} {activeFormats.map(f => (
className={`${s.chip} ${formats.includes(f) ? s.chipActive : ''}`} <Tag.CheckableTag
onClick={() => toggleFormat(f)} key={f}
> checked={formats.includes(f)}
{formats.includes(f) && <span className="material-symbols-outlined" style={{fontSize:16}}>done</span>} onChange={() => toggleFormat(f)}
{f.toUpperCase()} >
</button> {f.toUpperCase()}
))} </Tag.CheckableTag>
</div> ))}
</Flex>
<div className={s.countBar}>
<span className={s.count}>{filtered.length} of {books.length} books</span>
{hasFilter && (
<button className={s.clearBtn} onClick={() => { setGenres([]); setFormats([]); setQuery('') }}>
Clear filters
</button>
)} )}
</div>
{/* ── Count / clear bar ── */}
<Flex align="center" gap={8} className={s.countBar}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{sorted.length} of {books.length} books
</Typography.Text>
{hasFilter && (
<Button
type="link"
size="small"
icon={<CloseOutlined />}
style={{ padding: 0, height: 'auto' }}
onClick={() => { setGenres([]); setFormats([]); setQuery('') }}
>
Clear filters
</Button>
)}
</Flex>
{/* ── Book grid / list ── */}
{viewMode === 'grid' ? (
<div className={s.grid}> <div className={s.grid}>
{filtered.map(book => ( {sorted.map(book => (
<BookCard <BookCard
key={book.id} key={book.id}
book={book} book={book}
selected={selected?.id === book.id} onClick={b => navigate(`/books/${b.id}`)}
onClick={b => setSelected(prev => prev?.id === b.id ? null : b)}
/> />
))} ))}
{filtered.length === 0 && ( {sorted.length === 0 && (
<p className={s.empty}>No books match your filters.</p> <p className={s.empty}>No books match your filters.</p>
)} )}
</div> </div>
) : (
<div className={s.list}>
{sorted.map(book => (
<BookRow
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
{sorted.length === 0 && (
<p className={s.empty}>No books match your filters.</p>
)}
</div> </div>
)}
<DetailPanel book={selected} onClose={() => setSelected(null)} /> <FloatButton
icon={<PlusOutlined />}
<button className={s.fab} onClick={() => setAddOpen(true)} aria-label="Add book"> type="primary"
<span className="material-symbols-outlined">add</span> onClick={() => setAddOpen(true)}
</button> tooltip="Add book"
style={{ bottom: 24, right: 24 }}
/>
{addOpen && ( {addOpen && (
<AddBookDialog <AddBookDialog
@@ -4,95 +4,51 @@
overflow: hidden; overflow: hidden;
} }
/* MD3 Navigation Drawer panel (permanent) used as book list */
.list { .list {
width: 272px; width: 260px;
min-width: 272px; min-width: 260px;
border-right: 1px solid var(--md-sys-color-outline-variant); border-right: 1px solid #f0f0f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: var(--md-sys-color-surface-container-low); background: #fafafa;
} }
.listHeader { .listHeader {
padding: 12px; padding: 10px;
border-bottom: 1px solid var(--md-sys-color-outline-variant); border-bottom: 1px solid #f0f0f0;
flex-shrink: 0; flex-shrink: 0;
} }
/* MD3 Search field (smaller variant) */
.search {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px;
background: var(--md-sys-color-surface-container-high);
border-radius: var(--md-sys-shape-full);
}
.searchIcon {
color: var(--md-sys-color-on-surface-variant);
font-size: 20px !important;
flex-shrink: 0;
}
.searchInput {
flex: 1;
border: none;
background: transparent;
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface);
}
.searchInput::placeholder {
color: var(--md-sys-color-on-surface-variant);
}
.bookList { .bookList {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 8px 4px; padding: 6px 4px;
list-style: none;
} }
/* MD3 List Item */
.bookItem { .bookItem {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 8px 12px; padding: 6px 10px;
border-radius: var(--md-sys-shape-full); border-radius: 20px;
cursor: pointer; cursor: pointer;
position: relative; transition: background 150ms;
overflow: hidden;
transition: background 200ms;
} }
.bookItem::before { .bookItem:hover {
content: ''; background: rgba(0, 0, 0, 0.04);
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-on-surface);
opacity: 0;
transition: opacity 200ms;
} }
.bookItem:hover::before { opacity: .08; }
.bookItem:active::before { opacity: .12; }
.bookItemActive { .bookItemActive {
background: var(--md-sys-color-secondary-container); background: #EDE7F6;
}
.bookItemActive::before {
background: var(--md-sys-color-on-secondary-container);
} }
.thumb { .thumb {
width: 32px; width: 30px;
height: 44px; height: 42px;
border-radius: var(--md-sys-shape-xs); border-radius: 3px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -107,50 +63,28 @@
min-width: 0; min-width: 0;
} }
.bookTitle {
display: block;
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookItemActive .bookTitle {
color: var(--md-sys-color-on-secondary-container);
font-weight: 500;
}
.bookAuthor {
display: block;
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Editor pane */ /* Editor pane */
.editor { .editor {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow-y: auto;
min-width: 0;
} }
.editorHeader { .editorHeader {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 14px;
padding: 16px 24px; padding: 14px 24px;
border-bottom: 1px solid var(--md-sys-color-outline-variant); border-bottom: 1px solid #f0f0f0;
flex-shrink: 0; flex-shrink: 0;
} }
.editorCover { .editorCover {
width: 44px; width: 42px;
height: 60px; height: 58px;
border-radius: var(--md-sys-shape-xs); border-radius: 3px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -160,22 +94,37 @@
flex-shrink: 0; flex-shrink: 0;
} }
.editorTitle { .formWrap {
font: var(--md-sys-typescale-title-large); padding: 16px 24px;
color: var(--md-sys-color-on-surface);
}
.editorAuthor {
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface-variant);
margin-top: 2px;
}
.empty {
flex: 1; flex: 1;
display: flex; }
align-items: center;
justify-content: center; /* Editions */
color: var(--md-sys-color-on-surface-variant); .editions {
font: var(--md-sys-typescale-body-large); padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.editionList {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.editionItem {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
}
.editionIcon {
font-size: 14px;
color: rgba(0,0,0,.45);
flex-shrink: 0;
align-self: center;
display: flex;
} }
+125 -16
View File
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { Book } from '../../types' import { useSearchParams } from 'react-router-dom'
import { fetchBooks, updateBook } from '../../api/books' import { Flex, Input, Typography } from 'antd'
import {
AudioOutlined,
ReadOutlined,
TabletOutlined,
} from '@ant-design/icons'
import type { Book, Edition, ReadingFormat } from '../../types'
import { fetchBooks, updateBook, fetchMetadataFromHardcover } from '../../api/books'
import MetadataForm from '../../components/MetadataForm/MetadataForm' import MetadataForm from '../../components/MetadataForm/MetadataForm'
import s from './Metadata.module.css' import s from './Metadata.module.css'
@@ -8,11 +15,15 @@ export default function Metadata() {
const [books, setBooks] = useState<Book[]>([]) const [books, setBooks] = useState<Book[]>([])
const [selected, setSelected] = useState<Book | null>(null) const [selected, setSelected] = useState<Book | null>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [fetchKey, setFetchKey] = useState(0)
const [searchParams] = useSearchParams()
useEffect(() => { useEffect(() => {
fetchBooks().then(list => { fetchBooks().then(list => {
setBooks(list) setBooks(list)
if (list.length > 0) setSelected(list[0]) const bookId = searchParams.get('bookId')
const target = bookId ? list.find(b => b.id === Number(bookId)) : null
setSelected(target ?? (list.length > 0 ? list[0] : null))
}) })
}, []) }, [])
@@ -31,21 +42,27 @@ export default function Metadata() {
}) })
} }
async function handleFetchMetadata() {
if (!selected) return
const updated = await fetchMetadataFromHardcover(selected.id)
setBooks(bs => bs.map(b => b.id === updated.id ? updated : b))
setSelected(updated)
setFetchKey(k => k + 1)
}
return ( return (
<div className={s.layout}> <div className={s.layout}>
{/* Book list sidebar */}
<aside className={s.list}> <aside className={s.list}>
<div className={s.listHeader}> <div className={s.listHeader}>
<div className={s.search}> <Input
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
<input
className={s.searchInput}
type="search"
placeholder="Filter books…" placeholder="Filter books…"
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
allowClear
size="small"
/> />
</div> </div>
</div>
<ul className={s.bookList}> <ul className={s.bookList}>
{filtered.map(book => ( {filtered.map(book => (
@@ -58,14 +75,31 @@ export default function Metadata() {
{book.title[0]} {book.title[0]}
</div> </div>
<div className={s.bookMeta}> <div className={s.bookMeta}>
<span className={s.bookTitle}>{book.title}</span> <Typography.Text
<span className={s.bookAuthor}>{book.authors.map(a => a.name).join(', ')}</span> ellipsis
style={{
display: 'block',
fontSize: 13,
fontWeight: selected?.id === book.id ? 500 : 400,
color: selected?.id === book.id ? '#21005D' : 'rgba(0,0,0,.85)',
}}
>
{book.title}
</Typography.Text>
<Typography.Text
type="secondary"
ellipsis
style={{ display: 'block', fontSize: 11 }}
>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
</aside> </aside>
{/* Editor pane */}
<main className={s.editor}> <main className={s.editor}>
{selected ? ( {selected ? (
<> <>
@@ -73,17 +107,92 @@ export default function Metadata() {
<div className={s.editorCover} style={{ background: selected.color }}> <div className={s.editorCover} style={{ background: selected.color }}>
{selected.title[0]} {selected.title[0]}
</div> </div>
<div> <div style={{ minWidth: 0 }}>
<p className={s.editorTitle}>{selected.title}</p> <Typography.Title level={5} style={{ margin: 0 }} ellipsis>
<p className={s.editorAuthor}>{selected.authors.map(a => a.name).join(', ')}</p> {selected.title}
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{selected.authors.map(a => a.name).join(', ')}
</Typography.Text>
</div> </div>
</div> </div>
<MetadataForm key={selected.id} book={selected} onSave={handleSave} />
<div className={s.formWrap}>
<MetadataForm
key={`${selected.id}-${fetchKey}`}
book={selected}
onSave={handleSave}
onFetchMetadata={selected.hardcoverId != null ? handleFetchMetadata : undefined}
/>
</div>
{selected.editions.length > 0 && (
<EditionsList editions={selected.editions} />
)}
</> </>
) : ( ) : (
<div className={s.empty}>Select a book to edit metadata</div> <Flex align="center" justify="center" style={{ flex: 1, height: '100%' }}>
<Typography.Text type="secondary">Select a book to edit metadata</Typography.Text>
</Flex>
)} )}
</main> </main>
</div> </div>
) )
} }
const FORMAT_ICON: Record<ReadingFormat, React.ReactNode> = {
Physical: <ReadOutlined />,
Audio: <AudioOutlined />,
Both: <ReadOutlined />,
Ebook: <TabletOutlined />,
}
function formatAudio(seconds: number) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function EditionsList({ editions }: { editions: Edition[] }) {
return (
<div className={s.editions}>
<Typography.Text
type="secondary"
style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.06em' }}
>
Editions from Hardcover ({editions.length})
</Typography.Text>
<ul className={s.editionList}>
{editions.map(ed => {
const icon = ed.readingFormat ? FORMAT_ICON[ed.readingFormat] : <ReadOutlined />
const label = ed.editionFormat ?? ed.readingFormat ?? '—'
const meta: string[] = []
if (ed.publisher) meta.push(ed.publisher)
if (ed.releaseYear) meta.push(String(ed.releaseYear))
if (ed.pages) meta.push(`${ed.pages} pp`)
if (ed.audioSeconds) meta.push(formatAudio(ed.audioSeconds))
if (ed.language && ed.language !== 'English') meta.push(ed.language)
return (
<li key={ed.id} className={s.editionItem}>
<span className={s.editionIcon}>{icon}</span>
<Typography.Text style={{ fontSize: 12, fontWeight: 500 }}>{label}</Typography.Text>
{(ed.isbn || ed.asin) && (
<Typography.Text
type="secondary"
style={{ fontSize: 11, fontFamily: 'monospace' }}
>
{ed.isbn ?? ed.asin}
</Typography.Text>
)}
{meta.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{meta.join(' · ')}
</Typography.Text>
)}
</li>
)
})}
</ul>
</div>
)
}
+56 -5
View File
@@ -1,12 +1,16 @@
export type Format = 'epub' | 'mobi' | 'pdf' | 'cbz' | 'cbr' export type Format = 'epub' | 'mobi' | 'pdf' | 'cbz' | 'cbr'
export type SeriesType = 'single-author' | 'multi-author' export type SeriesType = 'single-author' | 'multi-author'
export type AuthorRole = 'author' | 'editor'
export type QueueStatus = 'queued' | 'downloading' | 'completed' | 'failed' export type QueueStatus = 'queued' | 'downloading' | 'completed' | 'failed'
export type SourceType = 'folder' | 'calibre' | 'opds' | 'url' export type SourceType = 'folder' | 'calibre' | 'opds' | 'url'
export interface Author { export interface Author {
id: number id: number
name: string name: string
bio: string | null
bornYear: number | null
imageUrl: string | null
slug: string | null
role: string
} }
export interface Series { export interface Series {
@@ -22,10 +26,22 @@ export interface SeriesEntry {
arc?: string arc?: string
} }
export interface BookAuthor { export type ReadingFormat = 'Physical' | 'Audio' | 'Both' | 'Ebook'
bookId: string
authorId: string export interface Edition {
role: AuthorRole id: number
isbn: string | null
asin: string | null
publisher: string | null
releaseYear: number | null
readingFormat: ReadingFormat | null
/** Detailed format from Hardcover, e.g. "Hardcover", "Mass Market Paperback" */
editionFormat: string | null
pages: number | null
audioSeconds: number | null
language: string | null
languageCode: string | null
coverUrl: string | null
} }
export interface Book { export interface Book {
@@ -44,6 +60,7 @@ export interface Book {
coverUrl: string | null coverUrl: string | null
isbn: string | null isbn: string | null
hardcoverId: number | null hardcoverId: number | null
editions: Edition[]
} }
export interface QueueItem { export interface QueueItem {
@@ -64,6 +81,25 @@ export interface HardcoverSearchResult {
genres: string[] genres: string[]
} }
export interface AuthorSummary {
id: number
name: string
bio: string | null
bornYear: number | null
imageUrl: string | null
bookCount: number
}
export interface AuthorDetail {
id: number
name: string
bio: string | null
bornYear: number | null
imageUrl: string | null
slug: string | null
books: Book[]
}
export interface ImportSource { export interface ImportSource {
id: string id: string
name: string name: string
@@ -71,3 +107,18 @@ export interface ImportSource {
path: string path: string
enabled: boolean enabled: boolean
} }
export type FileFormat = 'epub' | 'mobi' | 'pdf' | 'm4b' | 'mp3' | 'aac' | 'flac'
export interface BookFile {
id: number
bookId: number | null
editionId: number | null
sourceId: string | null
path: string
filename: string
sizeBytes: number
format: FileFormat
hash: string | null
addedAt: string
}
+22
View File
@@ -1 +1,23 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
// Ant Design uses ResizeObserver
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
// Ant Design uses matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
+20
View File
@@ -1,4 +1,15 @@
services: services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: pagemanager
POSTGRES_USER: pm
POSTGRES_PASSWORD: pm
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
pagemanager.api: pagemanager.api:
image: pagemanager.api image: pagemanager.api
build: build:
@@ -8,6 +19,11 @@ services:
- "5278:8080" - "5278:8080"
environment: environment:
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__Postgres=Host=postgres;Database=pagemanager;Username=pm;Password=pm
volumes:
- books:/data/books
depends_on:
- postgres
pagemanager.web: pagemanager.web:
image: pagemanager.web image: pagemanager.web
@@ -18,3 +34,7 @@ services:
- "8080:80" - "8080:80"
depends_on: depends_on:
- pagemanager.api - pagemanager.api
volumes:
pgdata:
books: