Changed design language. Added editions, better support for authors. Base for file handling
This commit is contained in:
@@ -34,6 +34,7 @@ public static class BookFactory
|
||||
HardcoverId = hardcoverId,
|
||||
BookAuthors = [],
|
||||
SeriesEntries = [],
|
||||
Editions = [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +54,13 @@ public static class BookFactory
|
||||
return book;
|
||||
}
|
||||
|
||||
public static Book WithEditions(this Book book, params (string? Isbn, string? Publisher, string? CoverUrl)[] editions)
|
||||
{
|
||||
foreach (var (isbn, publisher, coverUrl) in editions)
|
||||
book.Editions.Add(new Edition { BookId = book.Id, Isbn = isbn, Publisher = publisher, CoverUrl = coverUrl });
|
||||
return book;
|
||||
}
|
||||
|
||||
public static Book WithSeries(this Book book, int seriesId = 1, string seriesName = "Test Series", double position = 1.0, string? arc = null)
|
||||
{
|
||||
var series = new Series { Id = seriesId, Name = seriesName };
|
||||
|
||||
@@ -30,7 +30,7 @@ public class BooksControllerTests : IAsyncLifetime
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"TRUNCATE book_authors, series_entries, books, authors, series RESTART IDENTITY CASCADE");
|
||||
"TRUNCATE book_files, book_authors, series_entries, editions, books, authors, series RESTART IDENTITY CASCADE");
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
@@ -166,7 +166,7 @@ public class BooksControllerTests : IAsyncLifetime
|
||||
Year = 1965,
|
||||
Publisher = "Ace Books",
|
||||
Pages = 412,
|
||||
Authors = ["Frank Herbert"],
|
||||
Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
|
||||
Genres = ["Science Fiction"],
|
||||
Isbn = "9780441013593",
|
||||
CoverColor = "#c4a35a",
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
// ── Editions DTO mapping ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookWithEditions_MapsEditionsToDto()
|
||||
{
|
||||
var book = BookFactory.Create(id: 10)
|
||||
.WithAuthors((1, "Author"))
|
||||
.WithEditions(
|
||||
("9780441013593", "Ace Books", "https://example.com/cover1.jpg"),
|
||||
(null, "Bantam", null));
|
||||
_repo.GetByIdAsync(10).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(10);
|
||||
|
||||
result!.Editions.Should().HaveCount(2);
|
||||
result.Editions[0].Isbn.Should().Be("9780441013593");
|
||||
result.Editions[0].Publisher.Should().Be("Ace Books");
|
||||
result.Editions[0].CoverUrl.Should().Be("https://example.com/cover1.jpg");
|
||||
result.Editions[1].Isbn.Should().BeNull();
|
||||
result.Editions[1].Publisher.Should().Be("Bantam");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookWithNoEditions_ReturnsEmptyEditionsArray()
|
||||
{
|
||||
var book = BookFactory.Create(id: 11).WithAuthors((1, "Author"));
|
||||
_repo.GetByIdAsync(11).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(11);
|
||||
|
||||
result!.Editions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── CreateFromHardcoverAsync ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -202,7 +235,7 @@ public class BooksServiceTests
|
||||
result.Should().BeNull();
|
||||
await _repo.DidNotReceive().CreateBookAsync(
|
||||
Arg.Any<PageManager.Api.Data.Models.Book>(),
|
||||
Arg.Any<IReadOnlyList<string>>(),
|
||||
Arg.Any<IReadOnlyList<HardcoverAuthor>>(),
|
||||
Arg.Any<(string, double, string?)?> ());
|
||||
}
|
||||
|
||||
@@ -219,7 +252,7 @@ public class BooksServiceTests
|
||||
Publisher = "Ace Books",
|
||||
Pages = 412,
|
||||
Description = "A sci-fi classic.",
|
||||
Authors = ["Frank Herbert"],
|
||||
Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
|
||||
Genres = ["Science Fiction"],
|
||||
Isbn = "9780441013593",
|
||||
CoverUrl = "https://example.com/cover.jpg",
|
||||
@@ -231,7 +264,7 @@ public class BooksServiceTests
|
||||
var createdBook = BookFactory.Create(id: 55, title: "Dune")
|
||||
.WithAuthors((1, "Frank Herbert"))
|
||||
.WithSeries(seriesName: "Dune Chronicles", position: 1.0);
|
||||
_repo.CreateBookAsync(Arg.Any<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);
|
||||
|
||||
var result = await _sut.CreateFromHardcoverAsync(123);
|
||||
@@ -242,7 +275,7 @@ public class BooksServiceTests
|
||||
result.Series!.Name.Should().Be("Dune Chronicles");
|
||||
await _repo.Received(1).CreateBookAsync(
|
||||
Arg.Is<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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
public class EditionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Isbn { get; set; }
|
||||
public string? Asin { get; set; }
|
||||
public string? Publisher { get; set; }
|
||||
public int? ReleaseYear { get; set; }
|
||||
public PageManager.Api.Data.Models.ReadingFormat? ReadingFormat { get; set; }
|
||||
public string? EditionFormat { get; set; }
|
||||
public int? Pages { get; set; }
|
||||
public int? AudioSeconds { get; set; }
|
||||
public string? Language { get; set; }
|
||||
public string? LanguageCode { get; set; }
|
||||
public string? CoverUrl { get; set; }
|
||||
}
|
||||
|
||||
public class BookSeriesDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
@@ -23,4 +39,5 @@ public class BookDto
|
||||
public string? CoverUrl { get; set; }
|
||||
public string? Isbn { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
public EditionDto[] Editions { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -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 class HardcoverAuthor
|
||||
{
|
||||
public int? HardcoverId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Bio { get; set; }
|
||||
public int? BornYear { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
public string? Slug { get; set; }
|
||||
public string Role { get; set; } = "Author";
|
||||
}
|
||||
|
||||
public class HardcoverBookDetails
|
||||
{
|
||||
public int Id { get; set; }
|
||||
@@ -16,13 +27,14 @@ public class HardcoverBookDetails
|
||||
public string? Description { get; set; }
|
||||
public int? Pages { get; set; }
|
||||
public int? Year { get; set; }
|
||||
public string[] Authors { get; set; } = [];
|
||||
public HardcoverAuthor[] Authors { get; set; } = [];
|
||||
public string[] Genres { get; set; } = [];
|
||||
public string? Isbn { get; set; }
|
||||
public string? Publisher { get; set; }
|
||||
public string? CoverUrl { get; set; }
|
||||
public string? CoverColor { get; set; }
|
||||
public HardcoverSeriesInfo? Series { get; set; }
|
||||
public HardcoverEdition[] Editions { get; set; } = [];
|
||||
}
|
||||
|
||||
public class HardcoverSeriesInfo
|
||||
@@ -31,6 +43,22 @@ public class HardcoverSeriesInfo
|
||||
public double Position { get; set; }
|
||||
}
|
||||
|
||||
public class HardcoverEdition
|
||||
{
|
||||
public string? Isbn { get; set; }
|
||||
public string? Asin { get; set; }
|
||||
public string? Publisher { get; set; }
|
||||
public int? ReleaseYear { get; set; }
|
||||
public PageManager.Api.Data.Models.ReadingFormat? ReadingFormat { get; set; }
|
||||
public string? EditionFormat { get; set; }
|
||||
public int? Pages { get; set; }
|
||||
public int? AudioSeconds { get; set; }
|
||||
public string? Language { get; set; }
|
||||
public string? LanguageCode { get; set; }
|
||||
public string? CoverUrl { get; set; }
|
||||
public string? CoverColor { get; set; }
|
||||
}
|
||||
|
||||
public class CreateBookFromHardcoverRequest
|
||||
{
|
||||
public int HardcoverId { get; set; }
|
||||
|
||||
@@ -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);
|
||||
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<BookAuthor> BookAuthors => Set<BookAuthor>();
|
||||
public DbSet<SeriesEntry> SeriesEntries => Set<SeriesEntry>();
|
||||
public DbSet<Edition> Editions => Set<Edition>();
|
||||
public DbSet<ImportSource> ImportSources => Set<ImportSource>();
|
||||
public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>();
|
||||
public DbSet<BookFile> BookFiles => Set<BookFile>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder model)
|
||||
{
|
||||
@@ -28,8 +30,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
.WithMany(a => a.BookAuthors)
|
||||
.HasForeignKey(ba => ba.AuthorId);
|
||||
|
||||
e.Property(ba => ba.Role)
|
||||
.HasConversion<string>();
|
||||
// Role is stored as plain text — no conversion needed
|
||||
});
|
||||
|
||||
// ── SeriesEntry (composite PK) ───────────────────────────────────────
|
||||
@@ -66,6 +67,17 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
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.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 int Id { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Bio { get; set; }
|
||||
public int? BornYear { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
public string? Slug { get; set; }
|
||||
|
||||
public ICollection<BookAuthor> BookAuthors { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -27,4 +27,6 @@ public class Book
|
||||
|
||||
public ICollection<BookAuthor> BookAuthors { 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;
|
||||
|
||||
public enum AuthorRole { Author, Editor }
|
||||
|
||||
public class BookAuthor
|
||||
{
|
||||
public int BookId { get; set; }
|
||||
@@ -10,5 +8,5 @@ public class BookAuthor
|
||||
public int AuthorId { get; set; }
|
||||
public Author Author { get; set; } = null!;
|
||||
|
||||
public AuthorRole Role { get; set; } = AuthorRole.Author;
|
||||
public string Role { get; set; } = "Author";
|
||||
}
|
||||
|
||||
@@ -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 PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Data.Repositories;
|
||||
@@ -9,6 +10,7 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
|
||||
db.Books
|
||||
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
|
||||
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
|
||||
.Include(b => b.Editions)
|
||||
.ToListAsync()
|
||||
.ContinueWith(t => (IEnumerable<Book>)t.Result);
|
||||
|
||||
@@ -16,34 +18,28 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
|
||||
db.Books
|
||||
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
|
||||
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
|
||||
.Include(b => b.Editions)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
|
||||
public Task<Book?> FindByHardcoverIdAsync(int hardcoverId) =>
|
||||
db.Books
|
||||
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
|
||||
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
|
||||
.Include(b => b.Editions)
|
||||
.FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId);
|
||||
|
||||
public async Task<Book> CreateBookAsync(
|
||||
Book book,
|
||||
IReadOnlyList<string> authorNames,
|
||||
IReadOnlyList<HardcoverAuthor> authors,
|
||||
(string name, double position, string? arc)? series)
|
||||
{
|
||||
db.Books.Add(book);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Resolve / create authors
|
||||
var authors = new List<Author>();
|
||||
foreach (var name in authorNames)
|
||||
{
|
||||
var author = await db.Authors.FirstOrDefaultAsync(a => a.Name == name)
|
||||
?? new Author { Name = name };
|
||||
if (author.Id == 0) db.Authors.Add(author);
|
||||
authors.Add(author);
|
||||
}
|
||||
if (authors.Any(a => a.Id == 0)) await db.SaveChangesAsync();
|
||||
foreach (var author in authors)
|
||||
db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id });
|
||||
// Resolve / create authors and link to book
|
||||
var resolved = await ResolveAuthorsAsync(authors);
|
||||
foreach (var (author, role) in resolved)
|
||||
db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id, Role = role });
|
||||
|
||||
// Resolve / create series
|
||||
if (series is { } si)
|
||||
@@ -66,8 +62,79 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
|
||||
return await db.Books
|
||||
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
|
||||
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
|
||||
.Include(b => b.Editions)
|
||||
.FirstAsync(b => b.Id == book.Id);
|
||||
}
|
||||
|
||||
public async Task<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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
namespace PageManager.Api.Data.Repositories;
|
||||
@@ -9,7 +10,8 @@ public interface IBooksRepository
|
||||
Task<Book?> FindByHardcoverIdAsync(int hardcoverId);
|
||||
Task<Book> CreateBookAsync(
|
||||
Book book,
|
||||
IReadOnlyList<string> authorNames,
|
||||
IReadOnlyList<HardcoverAuthor> authors,
|
||||
(string name, double position, string? arc)? series);
|
||||
Task<Book> SyncHardcoverDataAsync(Book book, IReadOnlyList<HardcoverAuthor> authors, IReadOnlyList<Edition> editions);
|
||||
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);
|
||||
}
|
||||
+375
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+532
@@ -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.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -30,11 +31,31 @@ namespace PageManager.Api.Migrations
|
||||
|
||||
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");
|
||||
|
||||
@@ -134,6 +155,144 @@ namespace PageManager.Api.Migrations
|
||||
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")
|
||||
@@ -277,6 +436,45 @@ namespace PageManager.Api.Migrations
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b =>
|
||||
{
|
||||
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
|
||||
.WithMany("BookFiles")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_book_files_books_book_id");
|
||||
|
||||
b.HasOne("PageManager.Api.Data.Models.Edition", "Edition")
|
||||
.WithMany("BookFiles")
|
||||
.HasForeignKey("EditionId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_book_files_editions_edition_id");
|
||||
|
||||
b.HasOne("PageManager.Api.Data.Models.ImportSource", "Source")
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_book_files_import_sources_source_id");
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Edition");
|
||||
|
||||
b.Navigation("Source");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
|
||||
{
|
||||
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
|
||||
.WithMany("Editions")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_editions_books_book_id");
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
|
||||
{
|
||||
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
|
||||
@@ -307,9 +505,20 @@ namespace PageManager.Api.Migrations
|
||||
{
|
||||
b.Navigation("BookAuthors");
|
||||
|
||||
b.Navigation("BookFiles");
|
||||
|
||||
b.Navigation("Editions");
|
||||
|
||||
b.Navigation("SeriesEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
|
||||
{
|
||||
b.Navigation("BookFiles");
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>PageManager.Api.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||
<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.AddScoped<IBooksRepository, BooksRepository>();
|
||||
builder.Services.AddScoped<IBooksService, BooksService>();
|
||||
builder.Services.AddScoped<IAuthorsRepository, AuthorsRepository>();
|
||||
builder.Services.AddScoped<IAuthorsService, AuthorsService>();
|
||||
builder.Services.AddHttpClient<IHardcoverService, HardcoverService>();
|
||||
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.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,
|
||||
Isbn = details.Isbn,
|
||||
HardcoverId = details.Id,
|
||||
Editions = details.Editions.Select(e => new Edition
|
||||
{
|
||||
Isbn = e.Isbn,
|
||||
Asin = e.Asin,
|
||||
Publisher = e.Publisher,
|
||||
ReleaseYear = e.ReleaseYear,
|
||||
ReadingFormat = e.ReadingFormat,
|
||||
EditionFormat = e.EditionFormat,
|
||||
Pages = e.Pages,
|
||||
AudioSeconds = e.AudioSeconds,
|
||||
Language = e.Language,
|
||||
LanguageCode = e.LanguageCode,
|
||||
CoverUrl = e.CoverUrl,
|
||||
CoverColor = e.CoverColor,
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
(string name, double position, string? arc)? seriesArg = details.Series is { } s
|
||||
@@ -72,7 +87,47 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
|
||||
return ToDto(created);
|
||||
}
|
||||
|
||||
private static BookDto ToDto(Book book)
|
||||
public async Task<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();
|
||||
return new BookDto
|
||||
@@ -87,7 +142,16 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
|
||||
Color = book.Color,
|
||||
Genres = book.Genres,
|
||||
Authors = book.BookAuthors
|
||||
.Select(ba => new AuthorDto { Id = ba.Author.Id, Name = ba.Author.Name })
|
||||
.Select(ba => new AuthorDto
|
||||
{
|
||||
Id = ba.Author.Id,
|
||||
Name = ba.Author.Name,
|
||||
Bio = ba.Author.Bio,
|
||||
BornYear = ba.Author.BornYear,
|
||||
ImageUrl = ba.Author.ImageUrl,
|
||||
Slug = ba.Author.Slug,
|
||||
Role = ba.Role,
|
||||
})
|
||||
.ToArray(),
|
||||
Series = entry is null ? null : new BookSeriesDto
|
||||
{
|
||||
@@ -98,6 +162,21 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
|
||||
CoverUrl = book.CoverUrl,
|
||||
Isbn = book.Isbn,
|
||||
HardcoverId = book.HardcoverId,
|
||||
Editions = book.Editions.Select(e => new EditionDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Isbn = e.Isbn,
|
||||
Asin = e.Asin,
|
||||
Publisher = e.Publisher,
|
||||
ReleaseYear = e.ReleaseYear,
|
||||
ReadingFormat = e.ReadingFormat,
|
||||
EditionFormat = e.EditionFormat,
|
||||
Pages = e.Pages,
|
||||
AudioSeconds = e.AudioSeconds,
|
||||
Language = e.Language,
|
||||
LanguageCode = e.LanguageCode,
|
||||
CoverUrl = e.CoverUrl,
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Microsoft.Extensions.Logging;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Services;
|
||||
|
||||
@@ -24,14 +25,31 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
|
||||
description
|
||||
pages
|
||||
release_year
|
||||
cached_contributors
|
||||
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) {
|
||||
position
|
||||
series { id name }
|
||||
}
|
||||
editions(order_by: {users_count: desc}, limit: 3) {
|
||||
editions(order_by: {users_count: desc}, limit: 10) {
|
||||
isbn_13
|
||||
asin
|
||||
pages
|
||||
release_year
|
||||
edition_format
|
||||
audio_seconds
|
||||
reading_format_id
|
||||
language { language code2 }
|
||||
publisher { name }
|
||||
image { url color }
|
||||
}
|
||||
@@ -86,9 +104,9 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
|
||||
|
||||
var details = ParseBookDetails(books[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,
|
||||
books[0].TryGetProperty("cached_contributors", out var cc) ? cc.GetRawText() : "missing");
|
||||
books[0].TryGetProperty("contributions", out var cc) ? cc.GetRawText() : "missing");
|
||||
|
||||
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? year = book.TryGetProperty("release_year",out var yEl) && yEl.ValueKind == JsonValueKind.Number ? yEl.GetInt32() : null;
|
||||
|
||||
var authors = ParseCachedContributors(book);
|
||||
var genres = ParseCachedTags(book);
|
||||
var series = ParseBookSeries(book);
|
||||
var (isbn, publisher, coverUrl, coverColor) = ParseEditions(book);
|
||||
var authors = ParseContributions(book);
|
||||
var genres = ParseCachedTags(book);
|
||||
var series = ParseBookSeries(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
|
||||
{
|
||||
@@ -163,40 +186,53 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
|
||||
CoverUrl = coverUrl,
|
||||
CoverColor = coverColor,
|
||||
Series = series,
|
||||
Editions = [.. editions],
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParseCachedContributors(JsonElement book)
|
||||
private static List<HardcoverAuthor> ParseContributions(JsonElement book)
|
||||
{
|
||||
var authors = new List<string>();
|
||||
if (!book.TryGetProperty("cached_contributors", out var el)) return authors;
|
||||
var result = new List<HardcoverAuthor>();
|
||||
if (!book.TryGetProperty("contributions", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
|
||||
if (el.ValueKind == JsonValueKind.String)
|
||||
foreach (var c in arr.EnumerateArray())
|
||||
{
|
||||
if (el.GetString() is not string s) return authors;
|
||||
using var inner = JsonDocument.Parse(s);
|
||||
ExtractContributorNames(inner.RootElement, authors);
|
||||
return authors;
|
||||
string role = "Author";
|
||||
if (c.TryGetProperty("contribution", out var roleEl) && roleEl.ValueKind == JsonValueKind.String)
|
||||
role = roleEl.GetString() ?? "Author";
|
||||
|
||||
if (!c.TryGetProperty("author", out var a) || a.ValueKind == JsonValueKind.Null)
|
||||
continue;
|
||||
|
||||
int? hardcoverId = a.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number
|
||||
? idEl.GetInt32() : null;
|
||||
string name = a.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
|
||||
string? bio = a.TryGetProperty("bio", out var bioEl) && bioEl.ValueKind == JsonValueKind.String
|
||||
? bioEl.GetString() : null;
|
||||
int? bornYear = a.TryGetProperty("born_year", out var byEl) && byEl.ValueKind == JsonValueKind.Number
|
||||
? byEl.GetInt32() : null;
|
||||
string? slug = a.TryGetProperty("slug", out var slEl) && slEl.ValueKind == JsonValueKind.String
|
||||
? slEl.GetString() : null;
|
||||
string? imageUrl = null;
|
||||
if (a.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null)
|
||||
if (imgEl.TryGetProperty("url", out var urlEl)) imageUrl = urlEl.GetString();
|
||||
|
||||
result.Add(new HardcoverAuthor
|
||||
{
|
||||
HardcoverId = hardcoverId,
|
||||
Name = name,
|
||||
Bio = bio,
|
||||
BornYear = bornYear,
|
||||
Slug = slug,
|
||||
ImageUrl = imageUrl,
|
||||
Role = role,
|
||||
});
|
||||
}
|
||||
|
||||
ExtractContributorNames(el, authors);
|
||||
return authors;
|
||||
}
|
||||
|
||||
private static void ExtractContributorNames(JsonElement el, List<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);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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())
|
||||
{
|
||||
if (isbn is null && ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null)
|
||||
isbn = isbnEl.GetString();
|
||||
string? isbn = ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null
|
||||
? isbnEl.GetString() : null;
|
||||
|
||||
if (publisher is null && ed.TryGetProperty("publisher", out var pubEl) && pubEl.ValueKind != JsonValueKind.Null)
|
||||
string? asin = ed.TryGetProperty("asin", out var asinEl) && asinEl.ValueKind != JsonValueKind.Null
|
||||
? asinEl.GetString() : null;
|
||||
|
||||
int? pages = ed.TryGetProperty("pages", out var pagesEl) && pagesEl.ValueKind == JsonValueKind.Number
|
||||
? pagesEl.GetInt32() : null;
|
||||
|
||||
int? releaseYear = ed.TryGetProperty("release_year", out var ryEl) && ryEl.ValueKind == JsonValueKind.Number
|
||||
? ryEl.GetInt32() : null;
|
||||
|
||||
string? editionFormat = ed.TryGetProperty("edition_format", out var efEl) && efEl.ValueKind != JsonValueKind.Null
|
||||
? efEl.GetString() : null;
|
||||
|
||||
int? audioSeconds = ed.TryGetProperty("audio_seconds", out var asEl) && asEl.ValueKind == JsonValueKind.Number
|
||||
? asEl.GetInt32() : null;
|
||||
|
||||
ReadingFormat? readingFormat = null;
|
||||
if (ed.TryGetProperty("reading_format_id", out var rfIdEl) && rfIdEl.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
var id = rfIdEl.GetInt32();
|
||||
if (Enum.IsDefined(typeof(ReadingFormat), id))
|
||||
readingFormat = (ReadingFormat)id;
|
||||
}
|
||||
|
||||
string? language = null, languageCode = null;
|
||||
if (ed.TryGetProperty("language", out var langEl) && langEl.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (langEl.TryGetProperty("language", out var lnEl)) language = lnEl.GetString();
|
||||
if (langEl.TryGetProperty("code2", out var lcEl)) languageCode = lcEl.GetString();
|
||||
}
|
||||
|
||||
string? publisher = null;
|
||||
if (ed.TryGetProperty("publisher", out var pubEl) && pubEl.ValueKind != JsonValueKind.Null)
|
||||
if (pubEl.TryGetProperty("name", out var pnEl)) publisher = pnEl.GetString();
|
||||
|
||||
if (coverUrl is null && ed.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null)
|
||||
string? coverUrl = null, coverColor = null;
|
||||
if (ed.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (imgEl.TryGetProperty("url", out var urlEl)) coverUrl = urlEl.GetString();
|
||||
if (imgEl.TryGetProperty("color", out var colorEl)) coverColor = colorEl.GetString();
|
||||
}
|
||||
|
||||
if (isbn is not null && publisher is not null && coverUrl is not null) break;
|
||||
result.Add(new HardcoverEdition
|
||||
{
|
||||
Isbn = isbn,
|
||||
Asin = asin,
|
||||
Publisher = publisher,
|
||||
ReleaseYear = releaseYear,
|
||||
ReadingFormat = readingFormat,
|
||||
EditionFormat = editionFormat,
|
||||
Pages = pages,
|
||||
AudioSeconds = audioSeconds,
|
||||
Language = language,
|
||||
LanguageCode = languageCode,
|
||||
CoverUrl = coverUrl,
|
||||
CoverColor = coverColor,
|
||||
});
|
||||
}
|
||||
|
||||
return (isbn, publisher, coverUrl, coverColor);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── HTTP ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
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": "*",
|
||||
"Hardcover": {
|
||||
"ApiKey": ""
|
||||
}
|
||||
},
|
||||
"LibraryPaths": []
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
<title>PageManager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+956
-2
File diff suppressed because it is too large
Load Diff
@@ -12,17 +12,19 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"antd": "^6.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "~5.7.2",
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
.content > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
/* App layout is handled by Ant Design Layout component */
|
||||
|
||||
+34
-12
@@ -1,22 +1,44 @@
|
||||
import { ConfigProvider, Layout } from 'antd'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import Sidebar from './components/Sidebar/Sidebar'
|
||||
import Library from './pages/Library/Library'
|
||||
import BookDetail from './pages/BookDetail/BookDetail'
|
||||
import Authors from './pages/Authors/Authors'
|
||||
import AuthorDetail from './pages/AuthorDetail/AuthorDetail'
|
||||
import Import from './pages/Import/Import'
|
||||
import Metadata from './pages/Metadata/Metadata'
|
||||
import s from './App.module.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className={s.shell}>
|
||||
<Sidebar />
|
||||
<div className={s.content}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/library" replace />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/import" element={<Import />} />
|
||||
<Route path="/metadata" element={<Metadata />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
<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 />
|
||||
<Layout.Content style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/library" replace />} />
|
||||
<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="/metadata" element={<Metadata />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -12,3 +12,7 @@ export function fetchBook(id: number): Promise<Book> {
|
||||
export function updateBook(id: number, patch: Partial<Book>): Promise<Book> {
|
||||
return api.put<Book>(`/books/${id}`, patch)
|
||||
}
|
||||
|
||||
export function fetchMetadataFromHardcover(id: number): Promise<Book> {
|
||||
return api.post<Book>(`/books/${id}/fetch-metadata`, {})
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* MD3 Dialog */
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 80vh;
|
||||
background: var(--md-sys-color-surface-container-high);
|
||||
border-radius: var(--md-sys-shape-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideUp 200ms cubic-bezier(.3,0,0,1);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(24px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 24px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font: var(--md-sys-typescale-headline-small);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* MD3 Icon Button */
|
||||
.closeBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.closeBtn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.closeBtn:hover::before { opacity: .08; }
|
||||
.closeBtn:active::before { opacity: .12; }
|
||||
|
||||
/* Search field */
|
||||
.searchWrap {
|
||||
padding: 16px 24px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
background: var(--md-sys-color-surface-container-highest);
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
/* Results list */
|
||||
.results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 16px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
/* MD3 List Item */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--md-sys-shape-md);
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--md-sys-color-on-surface);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.row:hover::before { opacity: .08; }
|
||||
.row:active::before { opacity: .12; }
|
||||
|
||||
.rowAdded {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rowAdded::before { background: var(--md-sys-color-primary); }
|
||||
.rowAdded:hover::before { opacity: .05; }
|
||||
|
||||
.rowContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
font: var(--md-sys-typescale-body-small);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.rowAction {
|
||||
flex-shrink: 0;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 20px !important;
|
||||
transition: color 200ms;
|
||||
}
|
||||
|
||||
.rowAdded .rowAction {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.rowLoading {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* AddBookDialog is implemented with Ant Design Modal */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Input, Modal, Spin, Typography } from 'antd'
|
||||
import { CheckCircleOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { Book, HardcoverSearchResult } from '../../types'
|
||||
import { searchHardcover, addBookFromHardcover } from '../../api/search'
|
||||
import s from './AddBookDialog.module.css'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
@@ -14,11 +15,10 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adding, setAdding] = useState<number | null>(null)
|
||||
const [added, setAdded] = useState<Set<number>>(new Set())
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => { inputRef.current?.focus() }, [])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setResults([]); return }
|
||||
const timer = setTimeout(() => {
|
||||
@@ -31,13 +31,6 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
async function handleAdd(result: HardcoverSearchResult) {
|
||||
if (adding !== null || added.has(result.id)) return
|
||||
setAdding(result.id)
|
||||
@@ -54,74 +47,85 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
|
||||
const showHint = !loading && !query.trim()
|
||||
|
||||
return (
|
||||
<div className={s.scrim} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className={s.dialog} role="dialog" aria-modal="true" aria-label="Add book">
|
||||
<div className={s.header}>
|
||||
<span className={s.heading}>Add book</span>
|
||||
<button className={s.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<Modal
|
||||
open
|
||||
onCancel={onClose}
|
||||
title="Add book"
|
||||
footer={null}
|
||||
width={520}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder="Search by title or author…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
allowClear
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<div className={s.searchWrap}>
|
||||
<div className={s.search}>
|
||||
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={s.searchInput}
|
||||
type="search"
|
||||
placeholder="Search by title or author…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div style={{ minHeight: 200, maxHeight: 400, overflowY: 'auto' }}>
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.results}>
|
||||
{loading && (
|
||||
<div className={s.spinner}>
|
||||
<span className="material-symbols-outlined">progress_activity</span>
|
||||
</div>
|
||||
)}
|
||||
{showHint && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
|
||||
Start typing to search Hardcover
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{showHint && (
|
||||
<p className={s.empty}>Start typing to search Hardcover</p>
|
||||
)}
|
||||
{showEmpty && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
|
||||
No results for "{query}"
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<p className={s.empty}>No results for "{query}"</p>
|
||||
)}
|
||||
{!loading && results.map(r => {
|
||||
const isAdded = added.has(r.id)
|
||||
const isAdding = adding === r.id
|
||||
|
||||
{!loading && results.map(r => {
|
||||
const isAdded = added.has(r.id)
|
||||
const isAdding = adding === r.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`${s.row} ${isAdded ? s.rowAdded : ''} ${isAdding ? s.rowLoading : ''}`}
|
||||
onClick={() => handleAdd(r)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }}
|
||||
aria-label={`Add ${r.title}`}
|
||||
>
|
||||
<div className={s.rowContent}>
|
||||
<div className={s.rowTitle}>{r.title}</div>
|
||||
<div className={s.rowMeta}>
|
||||
{r.authors.join(', ')}
|
||||
{r.year ? ` · ${r.year}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`material-symbols-outlined ${s.rowAction}`}>
|
||||
{isAdding ? 'progress_activity' : isAdded ? 'check_circle' : 'add'}
|
||||
</span>
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
onClick={() => handleAdd(r)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 8px',
|
||||
borderRadius: 6,
|
||||
cursor: isAdded ? 'default' : 'pointer',
|
||||
background: isAdded ? '#f6f0ff' : 'transparent',
|
||||
opacity: isAdding ? 0.6 : 1,
|
||||
transition: 'background 150ms',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'rgba(0,0,0,.03)'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Typography.Text strong ellipsis style={{ display: 'block' }}>{r.title}</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ fontSize: 13, display: 'block' }}>
|
||||
{r.authors.join(', ')}
|
||||
{r.year ? ` · ${r.year}` : ''}
|
||||
</Typography.Text>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
/* MD3 Elevated Card */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
border-radius: var(--md-sys-shape-md);
|
||||
box-shadow: var(--md-sys-elevation-1);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.06);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: box-shadow 200ms cubic-bezier(.2,0,0,1);
|
||||
transition: box-shadow 200ms, transform 150ms;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.card.selected {
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
outline: 2px solid var(--md-sys-color-primary);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.12);
|
||||
outline: 2px solid #6750A4;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* State layer for hover/press */
|
||||
.stateLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: var(--md-sys-color-on-surface);
|
||||
background: #000;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
.card:hover .stateLayer { opacity: .08; }
|
||||
.card:active .stateLayer { opacity: .12; }
|
||||
.card.selected .stateLayer { opacity: .08; background: var(--md-sys-color-primary); }
|
||||
.card:hover .stateLayer { opacity: .04; }
|
||||
.card:active .stateLayer { opacity: .08; }
|
||||
.card.selected .stateLayer { opacity: .04; background: #6750A4; }
|
||||
|
||||
.cover {
|
||||
aspect-ratio: 2/3;
|
||||
@@ -66,23 +64,39 @@
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,.45);
|
||||
background: rgba(0,0,0,.5);
|
||||
color: #fff;
|
||||
font: var(--md-sys-typescale-label-small);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 8px 10px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.year {
|
||||
font-size: 11px;
|
||||
color: rgba(0,0,0,.45);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--md-sys-typescale-title-small);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0,0,0,.85);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -91,8 +105,8 @@
|
||||
}
|
||||
|
||||
.author {
|
||||
font: var(--md-sys-typescale-body-small);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,.45);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -101,19 +115,5 @@
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* MD3 Assist Chip */
|
||||
.chip {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--md-sys-shape-sm);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
font: var(--md-sys-typescale-label-small);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Tag } from 'antd'
|
||||
import type { Book } from '../../types'
|
||||
import s from './BookCard.module.css'
|
||||
|
||||
@@ -33,14 +34,18 @@ export default function BookCard({ book, onClick, selected }: Props) {
|
||||
<div className={s.body}>
|
||||
<p className={s.title}>{book.title}</p>
|
||||
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p>
|
||||
<div className={s.chips}>
|
||||
{book.formats.map(f => (
|
||||
<span key={f} className={s.chip}>{f.toUpperCase()}</span>
|
||||
))}
|
||||
<div className={s.meta}>
|
||||
{book.year && <span className={s.year}>{book.year}</span>}
|
||||
<div className={s.chips}>
|
||||
{book.formats.map(f => (
|
||||
<Tag key={f} style={{ fontSize: 11, lineHeight: '20px', padding: '0 5px', margin: 0 }}>
|
||||
{f.toUpperCase()}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MD3 state layer */}
|
||||
<div className={s.stateLayer} />
|
||||
</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 */
|
||||
.scrim {
|
||||
display: none; /* hidden on wide screens; modal on narrow */
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
height: 100%;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 250ms cubic-bezier(.3,0,0,1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sheet.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 8px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* MD3 Icon Button */
|
||||
.closeBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: color 200ms;
|
||||
}
|
||||
|
||||
.closeBtn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--md-sys-color-on-surface-variant);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
.closeBtn:hover::before { opacity: .08; }
|
||||
.closeBtn:active::before { opacity: .12; }
|
||||
|
||||
.heading {
|
||||
font: var(--md-sys-typescale-title-large);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.cover {
|
||||
margin: 12px 16px;
|
||||
height: 180px;
|
||||
border-radius: var(--md-sys-shape-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coverImg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: var(--md-sys-shape-sm);
|
||||
}
|
||||
|
||||
.coverInitials {
|
||||
font-size: 3rem;
|
||||
font-weight: 300;
|
||||
color: rgba(255,255,255,.3);
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--md-sys-typescale-headline-small);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.author {
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.series {
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* MD3 Suggestion Chip */
|
||||
.formatChip {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--md-sys-shape-sm);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
background: var(--md-sys-color-surface-container-highest);
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 20px !important;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font: var(--md-sys-typescale-label-small);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.genres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* MD3 Filter Chip */
|
||||
.genreChip {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--md-sys-shape-sm);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--md-sys-typescale-body-medium);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
line-height: 1.6;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* MD3 Filled Button */
|
||||
.btnFilled {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
background: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 200ms;
|
||||
}
|
||||
|
||||
.btnFilled .material-symbols-outlined { font-size: 18px !important; }
|
||||
|
||||
.btnFilled::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--md-sys-color-on-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); }
|
||||
.btnFilled:hover::before { opacity: .08; }
|
||||
.btnFilled:active::before { opacity: .12; }
|
||||
|
||||
/* MD3 Filled Tonal Button */
|
||||
.btnTonal {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 200ms;
|
||||
}
|
||||
|
||||
.btnTonal .material-symbols-outlined { font-size: 18px !important; }
|
||||
|
||||
.btnTonal::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--md-sys-color-on-secondary-container);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.btnTonal:hover { box-shadow: var(--md-sys-elevation-1); }
|
||||
.btnTonal:hover::before { opacity: .08; }
|
||||
.btnTonal:active::before { opacity: .12; }
|
||||
/* DetailPanel is implemented with Ant Design Drawer */
|
||||
|
||||
@@ -1,97 +1,198 @@
|
||||
import type { Book } from '../../types'
|
||||
import s from './DetailPanel.module.css'
|
||||
import { Button, Divider, Drawer, Flex, Space, Tag, Typography } from 'antd'
|
||||
import {
|
||||
BankOutlined,
|
||||
CalendarOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
ReadOutlined,
|
||||
TabletOutlined,
|
||||
AudioOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { Book, Edition, ReadingFormat } from '../../types'
|
||||
|
||||
interface Props {
|
||||
book: Book | null
|
||||
onClose: () => void
|
||||
onEditMetadata?: (book: Book) => void
|
||||
}
|
||||
|
||||
export default function DetailPanel({ book, onClose }: Props) {
|
||||
export default function DetailPanel({ book, onClose, onEditMetadata }: Props) {
|
||||
return (
|
||||
<>
|
||||
{book && <div className={s.scrim} onClick={onClose} />}
|
||||
<aside className={`${s.sheet} ${book ? s.open : ''}`}>
|
||||
{book && (
|
||||
<>
|
||||
<div className={s.header}>
|
||||
<button className={s.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
<h2 className={s.heading}>Book details</h2>
|
||||
</div>
|
||||
|
||||
<div className={s.cover} style={{ background: book.color }}>
|
||||
{book.coverUrl
|
||||
? <img className={s.coverImg} src={book.coverUrl} alt={book.title} />
|
||||
: <span className={s.coverInitials}>
|
||||
{book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={s.body}>
|
||||
<h3 className={s.title}>{book.title}</h3>
|
||||
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p>
|
||||
<Drawer
|
||||
open={!!book}
|
||||
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 && (
|
||||
<>
|
||||
{/* Cover */}
|
||||
<div style={{
|
||||
height: 190,
|
||||
background: book.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{book.coverUrl
|
||||
? <img
|
||||
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()}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<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 && (
|
||||
<p className={s.series}>
|
||||
{book.series.name} · Book {book.series.position}
|
||||
{book.series.arc ? ` · ${book.series.arc}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={s.chips}>
|
||||
{book.formats.map(f => (
|
||||
<span key={f} className={s.formatChip}>{f.toUpperCase()}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={s.divider} />
|
||||
|
||||
<div className={s.stats}>
|
||||
{book.year && <Stat icon="calendar_today" label="Year" value={String(book.year)} />}
|
||||
{book.pages && <Stat icon="menu_book" label="Pages" value={String(book.pages)} />}
|
||||
{book.publisher && <Stat icon="business" label="Publisher" value={book.publisher} />}
|
||||
</div>
|
||||
|
||||
{book.genres.length > 0 && (
|
||||
<div className={s.genres}>
|
||||
{book.genres.map(g => (
|
||||
<span key={g} className={s.genreChip}>{g}</span>
|
||||
))}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text style={{ fontSize: 13, color: '#6750A4' }}>
|
||||
{book.series.name} · Book {book.series.position}
|
||||
{book.series.arc ? ` · ${book.series.arc}` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.description && (
|
||||
<p className={s.description}>{book.description}</p>
|
||||
)}
|
||||
|
||||
<div className={s.actions}>
|
||||
<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>
|
||||
</>
|
||||
|
||||
<Flex gap={6} wrap="wrap">
|
||||
{book.formats.map(f => (
|
||||
<Tag key={f}>{f.toUpperCase()}</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{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.genres.length > 0 && (
|
||||
<Flex gap={6} wrap="wrap">
|
||||
{book.genres.map(g => (
|
||||
<Tag key={g} color="purple">{g}</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{book.description && (
|
||||
<Typography.Paragraph style={{ fontSize: 13, color: 'rgba(0,0,0,.65)', lineHeight: 1.6, marginBottom: 0 }}>
|
||||
{book.description}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{book.editions.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||||
function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className={s.stat}>
|
||||
<span className={`material-symbols-outlined ${s.statIcon}`}>{icon}</span>
|
||||
<Flex align="flex-start" gap={10}>
|
||||
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 16, marginTop: 1, display: 'flex' }}>{icon}</span>
|
||||
<div>
|
||||
<p className={s.statLabel}>{label}</p>
|
||||
<p className={s.statValue}>{value}</p>
|
||||
<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>
|
||||
</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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldFull {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── MD3 Outlined Text Field ── */
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.textareaWrap {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
outline: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
height: auto;
|
||||
resize: vertical;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
/* The visible border is the fieldset */
|
||||
.fieldset {
|
||||
position: absolute;
|
||||
inset: -5px 0 0;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
transition: border-color 200ms, border-width 200ms;
|
||||
}
|
||||
|
||||
.legend {
|
||||
font-size: .75rem;
|
||||
line-height: 0;
|
||||
padding: 0;
|
||||
width: 0; /* collapsed by default; expands on focus/filled */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: width 200ms cubic-bezier(.2,0,0,1);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
pointer-events: none;
|
||||
transition: top 150ms cubic-bezier(.2,0,0,1),
|
||||
font-size 150ms cubic-bezier(.2,0,0,1),
|
||||
line-height 150ms cubic-bezier(.2,0,0,1),
|
||||
color 150ms;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.labelTextarea {
|
||||
top: 20px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Floating label when input has value or is focused */
|
||||
.input:focus ~ .label,
|
||||
.input:not(:placeholder-shown) ~ .label {
|
||||
top: 0;
|
||||
transform: translateY(-50%);
|
||||
font-size: .75rem;
|
||||
line-height: 1rem;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
padding: 0 4px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.input:focus ~ .label {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.input:focus ~ .fieldset {
|
||||
border-color: var(--md-sys-color-primary);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.input:focus ~ .fieldset .legend,
|
||||
.input:not(:placeholder-shown) ~ .fieldset .legend {
|
||||
width: auto;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.textarea:focus ~ .label,
|
||||
.textarea:not(:placeholder-shown) ~ .label {
|
||||
top: 0;
|
||||
transform: translateY(-50%);
|
||||
font-size: .75rem;
|
||||
line-height: 1rem;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
padding: 0 4px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.textarea:focus ~ .label {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.textarea:focus ~ .fieldset {
|
||||
border-color: var(--md-sys-color-primary);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.supporting {
|
||||
font: var(--md-sys-typescale-body-small);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
padding: 4px 16px 0;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* MD3 Outlined Button */
|
||||
.btnOutlined {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
color: var(--md-sys-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
transition: box-shadow 200ms;
|
||||
}
|
||||
|
||||
.btnOutlined .material-symbols-outlined { font-size: 18px !important; }
|
||||
|
||||
.btnOutlined::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--md-sys-color-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.btnOutlined:hover::before { opacity: .08; }
|
||||
.btnOutlined:active::before { opacity: .12; }
|
||||
|
||||
/* MD3 Filled Button */
|
||||
.btnFilled {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
background: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 200ms, background 300ms;
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btnFilled .material-symbols-outlined { font-size: 18px !important; }
|
||||
|
||||
.btnFilled::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--md-sys-color-on-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); }
|
||||
.btnFilled:hover::before { opacity: .08; }
|
||||
|
||||
.btnSaved {
|
||||
background: var(--md-sys-color-success);
|
||||
}
|
||||
/* MetadataForm uses Ant Design components with inline styles */
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Button, Flex, Input, Space } from 'antd'
|
||||
import { CheckOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import type { Book } from '../../types'
|
||||
import { toForm } from './utils'
|
||||
import type { FormState } from './utils'
|
||||
import s from './MetadataForm.module.css'
|
||||
|
||||
interface Props {
|
||||
book: Book
|
||||
onSave: (patch: Partial<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 [saved, setSaved] = useState(false)
|
||||
const [fetching, setFetching] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setForm(toForm(book))
|
||||
setSaved(false)
|
||||
setFetchError(null)
|
||||
}, [book.id])
|
||||
|
||||
const set = (field: keyof FormState) =>
|
||||
@@ -37,101 +42,104 @@ export default function MetadataForm({ book, onSave }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={s.form} onSubmit={handleSave}>
|
||||
<div className={s.row}>
|
||||
<OutlinedField label="Title" value={form.title} onChange={set('title')} grow />
|
||||
</div>
|
||||
<div className={s.row}>
|
||||
<OutlinedField label="Author(s)" value={form.authors} onChange={set('authors')} grow
|
||||
supporting="Comma-separated" />
|
||||
</div>
|
||||
<div className={s.row}>
|
||||
<OutlinedField label="Series" value={form.series} onChange={set('series')} grow />
|
||||
<OutlinedField label="Position" value={form.seriesPosition} onChange={set('seriesPosition')} width={96} type="number" />
|
||||
</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')} />
|
||||
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Flex gap={12}>
|
||||
<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>
|
||||
</Flex>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>
|
||||
Author(s) <span style={{ fontStyle: 'italic' }}>(comma-separated)</span>
|
||||
</label>
|
||||
<Input value={form.authors} onChange={set('authors')} />
|
||||
</div>
|
||||
|
||||
<div className={s.footer}>
|
||||
<button type="button" className={s.btnOutlined}>
|
||||
<span className="material-symbols-outlined">sync</span>
|
||||
Fetch Metadata
|
||||
</button>
|
||||
<button type="submit" className={`${s.btnFilled} ${saved ? s.btnSaved : ''}`}>
|
||||
{saved
|
||||
? <><span className="material-symbols-outlined">check</span> Saved</>
|
||||
: 'Save'}
|
||||
</button>
|
||||
<Flex gap={12}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Series</label>
|
||||
<Input value={form.series} onChange={set('series')} />
|
||||
</div>
|
||||
<div style={{ width: 96 }}>
|
||||
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Position</label>
|
||||
<Input type="number" value={form.seriesPosition} onChange={set('seriesPosition')} />
|
||||
</div>
|
||||
</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>
|
||||
</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 */
|
||||
.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; }
|
||||
/* QueueItem uses Ant Design components with inline styles */
|
||||
|
||||
@@ -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 { formatBytes } from './utils'
|
||||
import s from './QueueItem.module.css'
|
||||
|
||||
interface Props {
|
||||
item: IQueueItem
|
||||
@@ -8,11 +14,11 @@ interface Props {
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<IQueueItem['status'], string> = {
|
||||
queued: 'schedule',
|
||||
downloading: 'downloading',
|
||||
completed: 'check_circle',
|
||||
failed: 'error',
|
||||
const STATUS_ICON: Record<IQueueItem['status'], React.ReactNode> = {
|
||||
queued: <ScheduleOutlined style={{ color: 'rgba(0,0,0,.45)' }} />,
|
||||
downloading: <LoadingOutlined spin style={{ color: '#6750A4' }} />,
|
||||
completed: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
|
||||
}
|
||||
|
||||
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)
|
||||
: 0
|
||||
|
||||
const sizeLabel = item.status === 'completed'
|
||||
? formatBytes(item.sizeBytes)
|
||||
: `${formatBytes(item.downloadedBytes)} / ${formatBytes(item.sizeBytes)}`
|
||||
|
||||
return (
|
||||
<div className={`${s.item} ${s[`status_${item.status}`]}`}>
|
||||
<span className={`material-symbols-outlined ${s.statusIcon} ${s[`icon_${item.status}`]}`}>
|
||||
<div style={{
|
||||
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]}
|
||||
</span>
|
||||
|
||||
<div className={s.content}>
|
||||
<div className={s.row}>
|
||||
<span className={s.filename}>{item.filename}</span>
|
||||
<span className={s.size}>
|
||||
{item.status === 'completed'
|
||||
? formatBytes(item.sizeBytes)
|
||||
: `${formatBytes(item.downloadedBytes)} / ${formatBytes(item.sizeBytes)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Flex align="baseline" gap={8}>
|
||||
<Typography.Text ellipsis style={{ flex: 1, fontSize: 14 }}>{item.filename}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>{sizeLabel}</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<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') && (
|
||||
<div className={s.progressTrack}>
|
||||
<div className={s.progressBar} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<Progress
|
||||
percent={pct}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#6750A4"
|
||||
style={{ margin: '2px 0 0' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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' && (
|
||||
<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>
|
||||
</div>
|
||||
<Button size="small" type="link" style={{ padding: 0, height: 'auto' }} onClick={() => onRemove(item.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,62 +1,101 @@
|
||||
import { Badge, Layout, Tooltip } from 'antd'
|
||||
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 {
|
||||
to: string
|
||||
label: string
|
||||
icon: string
|
||||
iconFilled: string
|
||||
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 NAV = [
|
||||
{ to: '/library', label: 'Library', icon: <BookOutlined />, badge: undefined as number | undefined },
|
||||
{ to: '/authors', label: 'Authors', icon: <TeamOutlined />, badge: undefined },
|
||||
{ to: '/import', label: 'Import', icon: <DownloadOutlined />, badge: undefined },
|
||||
{ to: '/metadata', label: 'Metadata', icon: <EditOutlined />, badge: undefined },
|
||||
]
|
||||
|
||||
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() {
|
||||
return (
|
||||
<nav className={s.rail}>
|
||||
<div className={s.brand}>
|
||||
<span className={`material-symbols-outlined ${s.brandIcon}`}>auto_stories</span>
|
||||
<Layout.Sider width={80} style={rail}>
|
||||
{/* Brand */}
|
||||
<div style={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 4 }}>
|
||||
<ReadOutlined style={{ fontSize: 26, color: '#6750A4' }} />
|
||||
</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 => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) => `${s.link} ${isActive ? s.active : ''}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className={s.indicator}>
|
||||
{item.badge !== undefined && (
|
||||
<span className={s.badge}>{item.badge}</span>
|
||||
)}
|
||||
<span
|
||||
className={`material-symbols-outlined ${s.icon} ${isActive ? s.iconFilled : ''}`}
|
||||
>
|
||||
{isActive ? item.iconFilled : item.icon}
|
||||
<NavLink key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
|
||||
{({ isActive }) => (
|
||||
<Tooltip title={item.label} placement="right">
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '8px 4px',
|
||||
borderRadius: 8,
|
||||
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>
|
||||
</div>
|
||||
<span className={s.label}>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
</Badge>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
lineHeight: 1,
|
||||
color: isActive ? '#1C1B1F' : '#49454F',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className={s.footer}>
|
||||
<button className={s.footerBtn}>
|
||||
<div className={s.indicator}>
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
{/* 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>
|
||||
<span className={s.label}>Settings</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</nav>
|
||||
</Layout.Sider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,101 +1,13 @@
|
||||
*, *::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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.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-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
/* Import page uses Ant Design components with inline styles */
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { QueueItem as IQueueItem, ImportSource } from '../../types'
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { fetchUnmatchedFiles, triggerScan } from '../../api/files'
|
||||
import QueueItem from '../../components/QueueItem/QueueItem'
|
||||
import s from './Import.module.css'
|
||||
|
||||
const SOURCE_ICONS: Record<string, string> = {
|
||||
folder: 'folder',
|
||||
calibre: 'auto_stories',
|
||||
opds: 'rss_feed',
|
||||
url: 'language',
|
||||
const SOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
folder: <FolderOutlined />,
|
||||
calibre: <BookOutlined />,
|
||||
opds: <WifiOutlined />,
|
||||
url: <GlobalOutlined />,
|
||||
}
|
||||
|
||||
export default function Import() {
|
||||
const [queue, setQueue] = useState<IQueueItem[]>([])
|
||||
const [sources, setSources] = useState<ImportSource[]>([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [queue, setQueue] = useState<IQueueItem[]>([])
|
||||
const [sources, setSources] = useState<ImportSource[]>([])
|
||||
const [unmatched, setUnmatched] = useState<BookFile[]>([])
|
||||
const [scanning, setScanning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue().then(setQueue)
|
||||
fetchSources().then(setSources)
|
||||
fetchUnmatchedFiles().then(setUnmatched)
|
||||
}, [])
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
console.log('dropped files:', Array.from(e.dataTransfer.files).map(f => f.name))
|
||||
function handleScan() {
|
||||
setScanning(true)
|
||||
triggerScan()
|
||||
.then(() => fetchUnmatchedFiles().then(setUnmatched))
|
||||
.finally(() => setScanning(false))
|
||||
}
|
||||
|
||||
function handleRetry(id: string) {
|
||||
@@ -52,96 +64,162 @@ export default function Import() {
|
||||
const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed')
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Left column */}
|
||||
<div className={s.col}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', padding: 24, gap: 24 }}>
|
||||
<section>
|
||||
<h2 className={s.heading}>Drop files</h2>
|
||||
<div
|
||||
className={`${s.dropzone} ${dragging ? s.dropping : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
<Typography.Title level={5} style={{ marginBottom: 12 }}>Drop files</Typography.Title>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
accept=".epub,.mobi,.pdf,.cbz,.cbr"
|
||||
showUploadList={false}
|
||||
beforeUpload={file => {
|
||||
console.log('file:', file.name)
|
||||
return false
|
||||
}}
|
||||
style={{ padding: '8px 0' }}
|
||||
>
|
||||
<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
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => console.log('files:', e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</Upload.Dragger>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={s.heading}>Sources</h2>
|
||||
<ul className={s.sourceList}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>Sources</Typography.Title>
|
||||
<div style={{ borderTop: '1px solid #f0f0f0' }}>
|
||||
{sources.map(src => (
|
||||
<li key={src.id} className={s.sourceItem}>
|
||||
<div className={s.sourceLeading}>
|
||||
<span className="material-symbols-outlined">
|
||||
{SOURCE_ICONS[src.type] ?? 'language'}
|
||||
</span>
|
||||
<div key={src.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
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 className={s.sourceInfo}>
|
||||
<span className={s.sourceName}>{src.name}</span>
|
||||
<span className={s.sourcePath}>{src.path}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text style={{ display: 'block', fontSize: 14 }}>{src.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>
|
||||
{src.path}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<button
|
||||
className={`${s.switch} ${src.enabled ? s.switchOn : ''}`}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
<Switch
|
||||
checked={src.enabled}
|
||||
onChange={() => toggleSource(src.id)}
|
||||
aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`}
|
||||
>
|
||||
<span className={s.switchThumb} />
|
||||
</button>
|
||||
</li>
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
<button className={s.addBtn}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</div>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => {}}
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<section>
|
||||
<h2 className={s.heading}>
|
||||
Downloading
|
||||
<span className={s.headingBadge}>{active.length}</span>
|
||||
</h2>
|
||||
<ul className={s.queueList}>
|
||||
<Flex align="center" gap={8} style={{ marginBottom: 12 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>Downloading</Typography.Title>
|
||||
<Badge count={active.length} color="#6750A4" />
|
||||
</Flex>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{active.map(item => (
|
||||
<li key={item.id}>
|
||||
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
</li>
|
||||
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{finished.length > 0 && (
|
||||
<section>
|
||||
<h2 className={s.heading}>History</h2>
|
||||
<ul className={s.queueList}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 12 }}>History</Typography.Title>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{finished.map(item => (
|
||||
<li key={item.id}>
|
||||
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
</li>
|
||||
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{queue.length === 0 && (
|
||||
<div className={s.empty}>No recent activity.</div>
|
||||
{queue.length === 0 && unmatched.length === 0 && (
|
||||
<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>
|
||||
|
||||
@@ -12,174 +12,44 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* MD3 Search bar */
|
||||
.searchWrap {
|
||||
padding: 16px 16px 8px;
|
||||
.topBar {
|
||||
padding: 14px 16px 10px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 16px 12px;
|
||||
align-items: center;
|
||||
padding: 0 16px 10px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px 4px;
|
||||
padding: 0 16px 6px;
|
||||
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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 24px;
|
||||
padding: 12px 16px 80px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
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; }
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
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 { fetchBooks } from '../../api/books'
|
||||
import { filterBooks } from './utils'
|
||||
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 s from './Library.module.css'
|
||||
|
||||
type ViewMode = 'grid' | 'list'
|
||||
type SortBy = 'title' | 'author' | 'year'
|
||||
|
||||
const ALL_FORMATS: Format[] = ['epub', 'mobi', 'pdf', 'cbz', 'cbr']
|
||||
|
||||
export default function Library() {
|
||||
const [books, setBooks] = useState<Book[]>([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [genres, setGenres] = useState<string[]>([])
|
||||
const [formats, setFormats] = useState<Format[]>([])
|
||||
const [selected, setSelected] = useState<Book | null>(null)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
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() {
|
||||
const [books, setBooks] = useState<Book[]>([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [genres, setGenres] = useState<string[]>([])
|
||||
const [formats, setFormats] = useState<Format[]>([])
|
||||
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)
|
||||
useEffect(() => { refreshBooks() }, [])
|
||||
|
||||
@@ -26,81 +44,131 @@ export default function Library() {
|
||||
const activeFormats = useMemo(() =>
|
||||
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 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 sorted = useMemo(() => sortBooks(filtered, sortBy), [filtered, sortBy])
|
||||
|
||||
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 hasFilter = genres.length > 0 || formats.length > 0 || Boolean(query)
|
||||
|
||||
return (
|
||||
<div className={s.layout}>
|
||||
<div className={s.main}>
|
||||
<div className={s.searchWrap}>
|
||||
<div className={s.search}>
|
||||
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
|
||||
<input
|
||||
className={s.searchInput}
|
||||
type="search"
|
||||
placeholder="Search books and authors…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
<div className={s.main}>
|
||||
|
||||
{/* ── Top bar ── */}
|
||||
<Flex gap={12} align="center" className={s.topBar}>
|
||||
<Input
|
||||
prefix={<span style={{ color: 'rgba(0,0,0,.45)' }}>⌕</span>}
|
||||
placeholder="Search books and authors…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
allowClear
|
||||
style={{ flex: 1, maxWidth: 520 }}
|
||||
/>
|
||||
<Space style={{ marginLeft: 'auto' }} size={8}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={v => setSortBy(v)}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Segmented
|
||||
value={viewMode}
|
||||
onChange={v => setViewMode(v as ViewMode)}
|
||||
options={[
|
||||
{ value: 'grid', icon: <AppstoreOutlined /> },
|
||||
{ value: 'list', icon: <UnorderedListOutlined /> },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<div className={s.chips}>
|
||||
{allGenres.map(g => (
|
||||
<button
|
||||
key={g}
|
||||
className={`${s.chip} ${genres.includes(g) ? s.chipActive : ''}`}
|
||||
onClick={() => toggleGenre(g)}
|
||||
>
|
||||
{genres.includes(g) && <span className="material-symbols-outlined" style={{fontSize:16}}>done</span>}
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
{activeFormats.length > 0 && <span className={s.divider} />}
|
||||
{activeFormats.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
className={`${s.chip} ${formats.includes(f) ? s.chipActive : ''}`}
|
||||
onClick={() => toggleFormat(f)}
|
||||
>
|
||||
{formats.includes(f) && <span className="material-symbols-outlined" style={{fontSize:16}}>done</span>}
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* ── Filter chips ── */}
|
||||
{(allGenres.length > 0 || activeFormats.length > 0) && (
|
||||
<Flex gap={6} wrap="wrap" align="center" className={s.chips}>
|
||||
{allGenres.map(g => (
|
||||
<Tag.CheckableTag
|
||||
key={g}
|
||||
checked={genres.includes(g)}
|
||||
onChange={() => toggleGenre(g)}
|
||||
>
|
||||
{g}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
{activeFormats.length > 0 && allGenres.length > 0 && (
|
||||
<Divider type="vertical" style={{ height: 18, margin: 'auto 2px', borderColor: '#d9d9d9' }} />
|
||||
)}
|
||||
{activeFormats.map(f => (
|
||||
<Tag.CheckableTag
|
||||
key={f}
|
||||
checked={formats.includes(f)}
|
||||
onChange={() => toggleFormat(f)}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<div className={s.countBar}>
|
||||
<span className={s.count}>{filtered.length} of {books.length} books</span>
|
||||
{/* ── 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 className={s.clearBtn} onClick={() => { setGenres([]); setFormats([]); setQuery('') }}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
onClick={() => { setGenres([]); setFormats([]); setQuery('') }}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<div className={s.grid}>
|
||||
{filtered.map(book => (
|
||||
<BookCard
|
||||
key={book.id}
|
||||
book={book}
|
||||
selected={selected?.id === book.id}
|
||||
onClick={b => setSelected(prev => prev?.id === b.id ? null : b)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className={s.empty}>No books match your filters.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailPanel book={selected} onClose={() => setSelected(null)} />
|
||||
|
||||
<button className={s.fab} onClick={() => setAddOpen(true)} aria-label="Add book">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
{/* ── Book grid / list ── */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className={s.grid}>
|
||||
{sorted.map(book => (
|
||||
<BookCard
|
||||
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 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>
|
||||
)}
|
||||
<FloatButton
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => setAddOpen(true)}
|
||||
tooltip="Add book"
|
||||
style={{ bottom: 24, right: 24 }}
|
||||
/>
|
||||
|
||||
{addOpen && (
|
||||
<AddBookDialog
|
||||
|
||||
@@ -4,95 +4,51 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* MD3 Navigation Drawer panel (permanent) used as book list */
|
||||
.list {
|
||||
width: 272px;
|
||||
min-width: 272px;
|
||||
border-right: 1px solid var(--md-sys-color-outline-variant);
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 4px;
|
||||
padding: 6px 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* MD3 List Item */
|
||||
.bookItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 200ms;
|
||||
transition: background 150ms;
|
||||
}
|
||||
|
||||
.bookItem::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--md-sys-color-on-surface);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
.bookItem:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.bookItem:hover::before { opacity: .08; }
|
||||
.bookItem:active::before { opacity: .12; }
|
||||
|
||||
.bookItemActive {
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
}
|
||||
|
||||
.bookItemActive::before {
|
||||
background: var(--md-sys-color-on-secondary-container);
|
||||
background: #EDE7F6;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 32px;
|
||||
height: 44px;
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
width: 30px;
|
||||
height: 42px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -107,50 +63,28 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
gap: 14px;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editorCover {
|
||||
width: 44px;
|
||||
height: 60px;
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
width: 42px;
|
||||
height: 58px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -160,22 +94,37 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editorTitle {
|
||||
font: var(--md-sys-typescale-title-large);
|
||||
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 {
|
||||
.formWrap {
|
||||
padding: 16px 24px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-large);
|
||||
}
|
||||
|
||||
/* Editions */
|
||||
.editions {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { Book } from '../../types'
|
||||
import { fetchBooks, updateBook } from '../../api/books'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
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 s from './Metadata.module.css'
|
||||
|
||||
export default function Metadata() {
|
||||
const [books, setBooks] = useState<Book[]>([])
|
||||
const [books, setBooks] = useState<Book[]>([])
|
||||
const [selected, setSelected] = useState<Book | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [fetchKey, setFetchKey] = useState(0)
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
fetchBooks().then(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,20 +42,26 @@ 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 (
|
||||
<div className={s.layout}>
|
||||
{/* Book list sidebar */}
|
||||
<aside className={s.list}>
|
||||
<div className={s.listHeader}>
|
||||
<div className={s.search}>
|
||||
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
|
||||
<input
|
||||
className={s.searchInput}
|
||||
type="search"
|
||||
placeholder="Filter books…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter books…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
allowClear
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className={s.bookList}>
|
||||
@@ -58,14 +75,31 @@ export default function Metadata() {
|
||||
{book.title[0]}
|
||||
</div>
|
||||
<div className={s.bookMeta}>
|
||||
<span className={s.bookTitle}>{book.title}</span>
|
||||
<span className={s.bookAuthor}>{book.authors.map(a => a.name).join(', ')}</span>
|
||||
<Typography.Text
|
||||
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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Editor pane */}
|
||||
<main className={s.editor}>
|
||||
{selected ? (
|
||||
<>
|
||||
@@ -73,17 +107,92 @@ export default function Metadata() {
|
||||
<div className={s.editorCover} style={{ background: selected.color }}>
|
||||
{selected.title[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className={s.editorTitle}>{selected.title}</p>
|
||||
<p className={s.editorAuthor}>{selected.authors.map(a => a.name).join(', ')}</p>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }} ellipsis>
|
||||
{selected.title}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{selected.authors.map(a => a.name).join(', ')}
|
||||
</Typography.Text>
|
||||
</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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
export type Format = 'epub' | 'mobi' | 'pdf' | 'cbz' | 'cbr'
|
||||
export type SeriesType = 'single-author' | 'multi-author'
|
||||
export type AuthorRole = 'author' | 'editor'
|
||||
export type QueueStatus = 'queued' | 'downloading' | 'completed' | 'failed'
|
||||
export type SourceType = 'folder' | 'calibre' | 'opds' | 'url'
|
||||
|
||||
export interface Author {
|
||||
id: number
|
||||
name: string
|
||||
bio: string | null
|
||||
bornYear: number | null
|
||||
imageUrl: string | null
|
||||
slug: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
@@ -22,10 +26,22 @@ export interface SeriesEntry {
|
||||
arc?: string
|
||||
}
|
||||
|
||||
export interface BookAuthor {
|
||||
bookId: string
|
||||
authorId: string
|
||||
role: AuthorRole
|
||||
export type ReadingFormat = 'Physical' | 'Audio' | 'Both' | 'Ebook'
|
||||
|
||||
export interface Edition {
|
||||
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 {
|
||||
@@ -44,6 +60,7 @@ export interface Book {
|
||||
coverUrl: string | null
|
||||
isbn: string | null
|
||||
hardcoverId: number | null
|
||||
editions: Edition[]
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
@@ -64,6 +81,25 @@ export interface HardcoverSearchResult {
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -71,3 +107,18 @@ export interface ImportSource {
|
||||
path: string
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1 +1,23 @@
|
||||
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,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
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:
|
||||
image: pagemanager.api
|
||||
build:
|
||||
@@ -8,6 +19,11 @@ services:
|
||||
- "5278:8080"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- ConnectionStrings__Postgres=Host=postgres;Database=pagemanager;Username=pm;Password=pm
|
||||
volumes:
|
||||
- books:/data/books
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
pagemanager.web:
|
||||
image: pagemanager.web
|
||||
@@ -18,3 +34,7 @@ services:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- pagemanager.api
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
books:
|
||||
|
||||
Reference in New Issue
Block a user