Project scaffolding
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
# ── .NET ──────────────────────────────────────────────────────────────────────
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
.vs/
|
||||
TestResults/
|
||||
*.suo
|
||||
*.ncrunch*
|
||||
|
||||
# Local secrets — never commit these
|
||||
**/appsettings.Development.json
|
||||
**/appsettings.Production.json
|
||||
**/appsettings.Local.json
|
||||
|
||||
# ── Node / React ───────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# ── IDE ────────────────────────────────────────────────────────────────────────
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# ── Misc ───────────────────────────────────────────────────────────────────────
|
||||
.claude/
|
||||
*.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,132 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
Mono-repo with two projects:
|
||||
|
||||
- `PageManager.Api/` — .NET 10 Web API (solution root contains `PageManager.Api.slnx`)
|
||||
- `PageManager.Api/PageManager.Api/` — the actual C# project
|
||||
- `PageManager.Api/PageManager.Api.Tests/` — xunit test project
|
||||
- `PageManager.Web/` — React 19 + Vite + TypeScript SPA
|
||||
- `compose.yaml` — root-level Docker Compose orchestrating both services
|
||||
|
||||
## Development Commands
|
||||
|
||||
### API (`PageManager.Api/`)
|
||||
|
||||
```bash
|
||||
cd PageManager.Api
|
||||
dotnet run --project PageManager.Api # runs on http://localhost:5278
|
||||
dotnet build
|
||||
dotnet test # all tests
|
||||
dotnet test --filter "FullyQualifiedName~Unit" # unit only (no Docker needed)
|
||||
dotnet test --filter "FullyQualifiedName~Integration" # integration only (Docker required)
|
||||
```
|
||||
|
||||
### Migrations (`PageManager.Api/`)
|
||||
|
||||
EF Core tooling targets the `PageManager.Api` project. All commands run from `PageManager.Api/`.
|
||||
|
||||
```bash
|
||||
# Create a migration after changing Data/Models
|
||||
dotnet ef migrations add <MigrationName> --project PageManager.Api
|
||||
|
||||
# Apply pending migrations to the database
|
||||
dotnet ef database update --project PageManager.Api
|
||||
|
||||
# Roll back to a specific migration
|
||||
dotnet ef database update <MigrationName> --project PageManager.Api
|
||||
|
||||
# Generate SQL script for a migration (useful for production deploys)
|
||||
dotnet ef migrations script --project PageManager.Api
|
||||
```
|
||||
|
||||
Install the EF global tool if not present: `dotnet tool install --global dotnet-ef`
|
||||
|
||||
### Web (`PageManager.Web/`)
|
||||
|
||||
```bash
|
||||
cd PageManager.Web
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run build
|
||||
npm run preview # preview production build locally
|
||||
npm test # single pass (CI)
|
||||
npm run test:watch # interactive watch (development)
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### Docker (both services)
|
||||
|
||||
```bash
|
||||
docker compose up --build # web → :8080, api → :5278
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### API → Web connection
|
||||
|
||||
**Dev:** Vite dev server proxies `/api/*` to `http://localhost:5278` (configured in `PageManager.Web/vite.config.ts`). The API enables CORS for `http://localhost:5173` in `Program.cs` — only in the `Development` environment.
|
||||
|
||||
**Production (Docker):** nginx (`PageManager.Web/nginx.conf`) proxies `/api/*` to `http://pagemanager.api:8080` (the compose service name). All other routes fall back to `index.html` for SPA routing.
|
||||
|
||||
## Conventions
|
||||
- Controllers are thin — logic lives in Services
|
||||
- EF Core models in Data/Models, DTOs in Api/Dtos
|
||||
- React components in PascalCase, files match component name
|
||||
- API calls go through src/api/*.ts typed wrappers, never raw fetch in components
|
||||
- Design reference: src/Leafreader.Web/design-reference.html
|
||||
|
||||
## Testing
|
||||
|
||||
**Rule: every code change must be followed by running the relevant tests and fixing any failures before the task is considered done.**
|
||||
|
||||
### After API changes
|
||||
```bash
|
||||
cd PageManager.Api
|
||||
dotnet test --filter "FullyQualifiedName~Unit" # always run; no Docker needed
|
||||
# If integration-relevant (endpoint, EF query, DB schema): also run:
|
||||
dotnet test --filter "FullyQualifiedName~Integration" # requires Docker
|
||||
```
|
||||
|
||||
### After Web changes
|
||||
```bash
|
||||
cd PageManager.Web
|
||||
npm test
|
||||
```
|
||||
|
||||
### Test locations
|
||||
|
||||
| Layer | Location | Tool |
|
||||
|---|---|---|
|
||||
| API unit | `PageManager.Api.Tests/Unit/Services/` | xunit + NSubstitute + FluentAssertions |
|
||||
| API integration | `PageManager.Api.Tests/Integration/` | xunit + Testcontainers (postgres:17-alpine) + WebApplicationFactory |
|
||||
| Web unit | `src/**/__tests__/` | Vitest + Testing Library |
|
||||
|
||||
### What to test and when
|
||||
|
||||
- **New service method or DTO mapping** → add unit test in `Unit/Services/`
|
||||
- **New or changed endpoint** → add integration test in `Integration/BooksControllerTests.cs` (or a new `*ControllerTests.cs`)
|
||||
- **New utility function** (`utils.ts`) → add unit test in a sibling `__tests__/` folder
|
||||
- **New React component with logic** → add component test in `__tests__/`
|
||||
- **Private logic inside a component** → extract to `utils.ts` first, then test the exported function
|
||||
|
||||
### What does NOT get tested
|
||||
- `BooksRepository` — pure EF Include chains, covered by integration tests
|
||||
- `BooksController` — too thin, covered by integration tests
|
||||
- CSS Modules, `Sidebar` (static nav), `App.tsx` routing
|
||||
- `importQueue.ts` — currently mock stubs; add tests when real HTTP is introduced
|
||||
|
||||
### Decision rules
|
||||
- Unit test: any service method, utility function, or DTO mapping with conditional logic or data transformation
|
||||
- Integration test: any new endpoint, EF Core query change, or PostgreSQL-specific feature
|
||||
- Don't duplicate: if unit tests own mapping correctness, integration tests own HTTP contract + DB round-trip
|
||||
- Extract before testing: private logic inside a component must move to `utils.ts` before writing tests
|
||||
|
||||
### Infrastructure notes
|
||||
- Integration tests use `[Collection("Postgres")]` — one container shared across all integration test classes
|
||||
- Each integration test class truncates tables in `InitializeAsync` to ensure isolation
|
||||
- `Book.Formats` and `Book.Genres` are `text[]` (PostgreSQL-specific) — always use real Postgres for integration tests, never InMemory
|
||||
- `TestWebAppFactory` sets `UseEnvironment("Testing")` — CORS and OpenAPI middleware are skipped
|
||||
@@ -0,0 +1,25 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -0,0 +1,3 @@
|
||||
global using Xunit;
|
||||
global using FluentAssertions;
|
||||
global using NSubstitute;
|
||||
@@ -0,0 +1,70 @@
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Tests.Helpers;
|
||||
|
||||
public static class BookFactory
|
||||
{
|
||||
public static Book Create(
|
||||
int id = 1,
|
||||
string title = "Test Book",
|
||||
int? year = 2024,
|
||||
string? publisher = "Test Publisher",
|
||||
int? pages = 300,
|
||||
string? description = "A test book.",
|
||||
string[] formats = null!,
|
||||
string color = "#6366f1",
|
||||
string[] genres = null!,
|
||||
string? coverUrl = null,
|
||||
string? isbn = null,
|
||||
int? hardcoverId = null)
|
||||
{
|
||||
return new Book
|
||||
{
|
||||
Id = id,
|
||||
Title = title,
|
||||
Year = year,
|
||||
Publisher = publisher,
|
||||
Pages = pages,
|
||||
Description = description,
|
||||
Formats = formats ?? ["epub"],
|
||||
Color = color,
|
||||
Genres = genres ?? ["Fiction"],
|
||||
CoverUrl = coverUrl,
|
||||
Isbn = isbn,
|
||||
HardcoverId = hardcoverId,
|
||||
BookAuthors = [],
|
||||
SeriesEntries = [],
|
||||
};
|
||||
}
|
||||
|
||||
public static Book WithAuthors(this Book book, params (int Id, string Name)[] authors)
|
||||
{
|
||||
foreach (var (id, name) in authors)
|
||||
{
|
||||
var author = new Author { Id = id, Name = name };
|
||||
book.BookAuthors.Add(new BookAuthor
|
||||
{
|
||||
BookId = book.Id,
|
||||
AuthorId = id,
|
||||
Author = author,
|
||||
Book = book,
|
||||
});
|
||||
}
|
||||
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 };
|
||||
book.SeriesEntries.Add(new SeriesEntry
|
||||
{
|
||||
SeriesId = seriesId,
|
||||
BookId = book.Id,
|
||||
Series = series,
|
||||
Book = book,
|
||||
Position = position,
|
||||
Arc = arc,
|
||||
});
|
||||
return book;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Data;
|
||||
using PageManager.Api.Data.Models;
|
||||
using PageManager.Api.Services;
|
||||
using PageManager.Api.Tests.Integration.Fixtures;
|
||||
|
||||
namespace PageManager.Api.Tests.Integration;
|
||||
|
||||
[Collection("Postgres")]
|
||||
public class BooksControllerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TestWebAppFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public BooksControllerTests(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_authors, series_entries, books, authors, series RESTART IDENTITY CASCADE");
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── GET /api/books ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetBooks_EmptyDb_Returns200WithEmptyArray()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/books");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var books = await response.Content.ReadFromJsonAsync<BookDto[]>();
|
||||
books.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBooks_SeededBooks_Returns200WithCorrectCountAndArrayFields()
|
||||
{
|
||||
await SeedBookAsync(title: "Dune", formats: ["epub", "mobi"], genres: ["Science Fiction"]);
|
||||
await SeedBookAsync(title: "Foundation", formats: ["pdf"], genres: ["Science Fiction", "Classic"]);
|
||||
|
||||
var response = await _client.GetAsync("/api/books");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var books = await response.Content.ReadFromJsonAsync<BookDto[]>();
|
||||
books.Should().HaveCount(2);
|
||||
var dune = books!.Single(b => b.Title == "Dune");
|
||||
dune.Formats.Should().BeEquivalentTo(new[] { "epub", "mobi" });
|
||||
dune.Genres.Should().BeEquivalentTo(new[] { "Science Fiction" });
|
||||
var foundation = books.Single(b => b.Title == "Foundation");
|
||||
foundation.Formats.Should().BeEquivalentTo(new[] { "pdf" });
|
||||
}
|
||||
|
||||
// ── GET /api/books/{id} ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetBook_Exists_Returns200WithDtoAndAuthors()
|
||||
{
|
||||
var id = await SeedBookAsync(title: "The Name of the Wind", authorName: "Patrick Rothfuss");
|
||||
|
||||
var response = await _client.GetAsync($"/api/books/{id}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var book = await response.Content.ReadFromJsonAsync<BookDto>();
|
||||
book.Should().NotBeNull();
|
||||
book!.Title.Should().Be("The Name of the Wind");
|
||||
book.Authors.Should().ContainSingle(a => a.Name == "Patrick Rothfuss");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBook_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/books/99999");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── PUT /api/books/{id} ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBook_Exists_Returns200WithUpdatedFields()
|
||||
{
|
||||
var id = await SeedBookAsync(title: "Original Title");
|
||||
|
||||
var req = new UpdateBookRequest
|
||||
{
|
||||
Title = "Updated Title",
|
||||
Year = 2025,
|
||||
Publisher = "New Publisher",
|
||||
Pages = 500,
|
||||
Formats = ["epub", "mobi"],
|
||||
Genres = ["Fantasy"],
|
||||
Color = "#ff0000",
|
||||
};
|
||||
|
||||
var response = await _client.PutAsJsonAsync($"/api/books/{id}", req);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var book = await response.Content.ReadFromJsonAsync<BookDto>();
|
||||
book!.Title.Should().Be("Updated Title");
|
||||
book.Year.Should().Be(2025);
|
||||
book.Publisher.Should().Be("New Publisher");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBook_NotFound_Returns404()
|
||||
{
|
||||
var req = new UpdateBookRequest { Title = "Whatever" };
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/books/99999", req);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBook_PersistsArrayFieldsToDb()
|
||||
{
|
||||
var id = await SeedBookAsync(title: "Array Test");
|
||||
|
||||
var req = new UpdateBookRequest
|
||||
{
|
||||
Title = "Array Test",
|
||||
Formats = ["epub", "pdf", "mobi"],
|
||||
Genres = ["Fantasy", "Adventure"],
|
||||
Color = "#6366f1",
|
||||
};
|
||||
|
||||
await _client.PutAsJsonAsync($"/api/books/{id}", req);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var book = await db.Books.FindAsync(id);
|
||||
book!.Formats.Should().BeEquivalentTo(["epub", "pdf", "mobi"]);
|
||||
book.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]);
|
||||
}
|
||||
|
||||
// ── POST /api/books ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBook_HardcoverReturnsDetails_Returns200WithCreatedBook()
|
||||
{
|
||||
var mockHardcover = Substitute.For<IHardcoverService>();
|
||||
mockHardcover.GetBookDetailsAsync(999).Returns(new HardcoverBookDetails
|
||||
{
|
||||
Id = 999,
|
||||
Title = "Dune",
|
||||
Year = 1965,
|
||||
Publisher = "Ace Books",
|
||||
Pages = 412,
|
||||
Authors = ["Frank Herbert"],
|
||||
Genres = ["Science Fiction"],
|
||||
Isbn = "9780441013593",
|
||||
CoverColor = "#c4a35a",
|
||||
});
|
||||
|
||||
using var client = _factory
|
||||
.WithWebHostBuilder(b => b.ConfigureServices(services =>
|
||||
{
|
||||
services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService)));
|
||||
services.AddScoped(_ => mockHardcover);
|
||||
}))
|
||||
.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 999 });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var book = await response.Content.ReadFromJsonAsync<BookDto>();
|
||||
book.Should().NotBeNull();
|
||||
book!.Title.Should().Be("Dune");
|
||||
book.HardcoverId.Should().Be(999);
|
||||
book.Authors.Should().ContainSingle(a => a.Name == "Frank Herbert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBook_HardcoverReturnsNull_Returns404()
|
||||
{
|
||||
var mockHardcover = Substitute.For<IHardcoverService>();
|
||||
mockHardcover.GetBookDetailsAsync(Arg.Any<int>()).Returns((HardcoverBookDetails?)null);
|
||||
|
||||
using var client = _factory
|
||||
.WithWebHostBuilder(b => b.ConfigureServices(services =>
|
||||
{
|
||||
services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService)));
|
||||
services.AddScoped(_ => mockHardcover);
|
||||
}))
|
||||
.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 0 });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBook_HardcoverIdAlreadyInDb_Returns200WithExistingBook()
|
||||
{
|
||||
await SeedBookWithHardcoverIdAsync(hardcoverId: 777, title: "Already Here");
|
||||
|
||||
var mockHardcover = Substitute.For<IHardcoverService>();
|
||||
|
||||
using var client = _factory
|
||||
.WithWebHostBuilder(b => b.ConfigureServices(services =>
|
||||
{
|
||||
services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService)));
|
||||
services.AddScoped(_ => mockHardcover);
|
||||
}))
|
||||
.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 777 });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var book = await response.Content.ReadFromJsonAsync<BookDto>();
|
||||
book!.Title.Should().Be("Already Here");
|
||||
await mockHardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any<int>());
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> SeedBookAsync(
|
||||
string title = "Test Book",
|
||||
string[] formats = null!,
|
||||
string[] genres = null!,
|
||||
string? authorName = null)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var book = new Book
|
||||
{
|
||||
Title = title,
|
||||
Formats = formats ?? [],
|
||||
Genres = genres ?? [],
|
||||
};
|
||||
db.Books.Add(book);
|
||||
|
||||
if (authorName is not null)
|
||||
{
|
||||
var author = new Author { Name = authorName };
|
||||
db.Authors.Add(author);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.BookAuthors.Add(new BookAuthor
|
||||
{
|
||||
BookId = book.Id,
|
||||
AuthorId = author.Id,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return book.Id;
|
||||
}
|
||||
|
||||
private async Task SeedBookWithHardcoverIdAsync(int hardcoverId, string title)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Books.Add(new Book { Title = title, HardcoverId = hardcoverId, Formats = [], Genres = [] });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Data;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace PageManager.Api.Tests.Integration.Fixtures;
|
||||
|
||||
public class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17-alpine")
|
||||
.Build();
|
||||
|
||||
public string ConnectionString => _container.GetConnectionString();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseNpgsql(ConnectionString)
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("Postgres")]
|
||||
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PageManager.Api.Data;
|
||||
|
||||
namespace PageManager.Api.Tests.Integration.Fixtures;
|
||||
|
||||
public class TestWebAppFactory(PostgresFixture postgres) : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||
if (descriptor is not null)
|
||||
services.Remove(descriptor);
|
||||
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(postgres.ConnectionString)
|
||||
.UseSnakeCaseNamingConvention());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
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 ImportControllerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TestWebAppFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ImportControllerTests(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 Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions
|
||||
.ExecuteSqlRawAsync(db.Database,
|
||||
"TRUNCATE import_queue_items, import_sources RESTART IDENTITY CASCADE");
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── GET /api/sources ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSources_EmptyDb_Returns200WithEmptyArray()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/sources");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var sources = await response.Content.ReadFromJsonAsync<ImportSourceDto[]>();
|
||||
sources.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSources_SeededSource_ReturnsMappedDto()
|
||||
{
|
||||
await SeedSourceAsync(name: "My Library", type: ImportSourceType.Folder, path: "/books");
|
||||
|
||||
var response = await _client.GetAsync("/api/sources");
|
||||
var sources = await response.Content.ReadFromJsonAsync<ImportSourceDto[]>();
|
||||
|
||||
sources.Should().HaveCount(1);
|
||||
sources![0].Name.Should().Be("My Library");
|
||||
sources[0].Type.Should().Be("folder");
|
||||
sources[0].Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── PATCH /api/sources/{id} ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSource_Found_Returns200WithUpdatedEnabled()
|
||||
{
|
||||
var id = await SeedSourceAsync();
|
||||
|
||||
var response = await _client.PatchAsJsonAsync($"/api/sources/{id}", new { enabled = false });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var dto = await response.Content.ReadFromJsonAsync<ImportSourceDto>();
|
||||
dto!.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSource_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.PatchAsJsonAsync("/api/sources/missing-id", new { enabled = false });
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── GET /api/queue ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetQueue_EmptyDb_Returns200WithEmptyArray()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/queue");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var items = await response.Content.ReadFromJsonAsync<QueueItemDto[]>();
|
||||
items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── DELETE /api/queue/{id} ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItem_Found_Returns204()
|
||||
{
|
||||
var id = await SeedQueueItemAsync();
|
||||
var response = await _client.DeleteAsync($"/api/queue/{id}");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItem_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/queue/missing-id");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── POST /api/queue/{id}/retry ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItem_FailedItem_Returns204()
|
||||
{
|
||||
var id = await SeedQueueItemAsync(status: QueueItemStatus.Failed);
|
||||
var response = await _client.PostAsync($"/api/queue/{id}/retry", null);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItem_NonFailedItem_Returns404()
|
||||
{
|
||||
var id = await SeedQueueItemAsync(status: QueueItemStatus.Completed);
|
||||
var response = await _client.PostAsync($"/api/queue/{id}/retry", null);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> SeedSourceAsync(
|
||||
string name = "Test Source",
|
||||
ImportSourceType type = ImportSourceType.Folder,
|
||||
string path = "/test")
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var source = new ImportSource { Name = name, Type = type, Path = path };
|
||||
db.ImportSources.Add(source);
|
||||
await db.SaveChangesAsync();
|
||||
return source.Id;
|
||||
}
|
||||
|
||||
private async Task<string> SeedQueueItemAsync(
|
||||
string filename = "book.epub",
|
||||
QueueItemStatus status = QueueItemStatus.Queued)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var item = new ImportQueueItem { Filename = filename, Status = status, Source = "https://example.com" };
|
||||
db.ImportQueueItems.Add(item);
|
||||
await db.SaveChangesAsync();
|
||||
return item.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PageManager.Api\PageManager.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
|
||||
<!-- Pin EF Core versions to match the main project (avoids version conflict from Testcontainers) -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,248 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Data.Repositories;
|
||||
using PageManager.Api.Services;
|
||||
using PageManager.Api.Tests.Helpers;
|
||||
|
||||
namespace PageManager.Api.Tests.Unit.Services;
|
||||
|
||||
public class BooksServiceTests
|
||||
{
|
||||
private readonly IBooksRepository _repo = Substitute.For<IBooksRepository>();
|
||||
private readonly IHardcoverService _hardcover = Substitute.For<IHardcoverService>();
|
||||
private readonly BooksService _sut;
|
||||
|
||||
public BooksServiceTests()
|
||||
{
|
||||
_sut = new BooksService(_repo, _hardcover);
|
||||
}
|
||||
|
||||
// ── GetAllAsync ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_MultipleBooks_ReturnsMappedDtos()
|
||||
{
|
||||
var books = new[]
|
||||
{
|
||||
BookFactory.Create(id: 1, title: "Book One").WithAuthors((1, "Author A")),
|
||||
BookFactory.Create(id: 2, title: "Book Two").WithAuthors((2, "Author B")),
|
||||
};
|
||||
_repo.GetAllAsync().Returns(books);
|
||||
|
||||
var result = (await _sut.GetAllAsync()).ToArray();
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result[0].Title.Should().Be("Book One");
|
||||
result[1].Title.Should().Be("Book Two");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_EmptyRepo_ReturnsEmptyCollection()
|
||||
{
|
||||
_repo.GetAllAsync().Returns([]);
|
||||
|
||||
var result = await _sut.GetAllAsync();
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── GetByIdAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookExists_ReturnsDtoWithCorrectFields()
|
||||
{
|
||||
var book = BookFactory.Create(id: 42, title: "Found Book", year: 2020, publisher: "Pub", pages: 100)
|
||||
.WithAuthors((7, "Jane Doe"))
|
||||
.WithSeries(seriesName: "Great Series", position: 2.0, arc: "Part One");
|
||||
_repo.GetByIdAsync(42).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(42);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(42);
|
||||
result.Title.Should().Be("Found Book");
|
||||
result.Year.Should().Be(2020);
|
||||
result.Publisher.Should().Be("Pub");
|
||||
result.Pages.Should().Be(100);
|
||||
result.Authors.Should().ContainSingle(a => a.Id == 7 && a.Name == "Jane Doe");
|
||||
result.Series.Should().NotBeNull();
|
||||
result.Series!.Name.Should().Be("Great Series");
|
||||
result.Series.Position.Should().Be(2.0);
|
||||
result.Series.Arc.Should().Be("Part One");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookNotFound_ReturnsNull()
|
||||
{
|
||||
_repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null);
|
||||
|
||||
var result = await _sut.GetByIdAsync(99);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── UpdateAsync ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_BookExists_AppliesAllFieldsAndSaves()
|
||||
{
|
||||
var book = BookFactory.Create(id: 5).WithAuthors((1, "Author"));
|
||||
_repo.GetByIdAsync(5).Returns(book);
|
||||
|
||||
var req = new UpdateBookRequest
|
||||
{
|
||||
Title = "Updated Title",
|
||||
Year = 2025,
|
||||
Publisher = "New Publisher",
|
||||
Pages = 999,
|
||||
Description = "New description",
|
||||
Formats = ["epub", "mobi"],
|
||||
Color = "#ff0000",
|
||||
Genres = ["Fantasy", "Adventure"],
|
||||
CoverUrl = "https://example.com/cover.jpg",
|
||||
Isbn = "978-0-00-000000-0",
|
||||
HardcoverId = 123,
|
||||
};
|
||||
|
||||
var result = await _sut.UpdateAsync(5, req);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Title.Should().Be("Updated Title");
|
||||
result.Year.Should().Be(2025);
|
||||
result.Publisher.Should().Be("New Publisher");
|
||||
result.Pages.Should().Be(999);
|
||||
result.Description.Should().Be("New description");
|
||||
result.Formats.Should().BeEquivalentTo(["epub", "mobi"]);
|
||||
result.Color.Should().Be("#ff0000");
|
||||
result.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]);
|
||||
result.CoverUrl.Should().Be("https://example.com/cover.jpg");
|
||||
result.Isbn.Should().Be("978-0-00-000000-0");
|
||||
result.HardcoverId.Should().Be(123);
|
||||
await _repo.Received(1).SaveAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_BookNotFound_ReturnsNullWithoutSaving()
|
||||
{
|
||||
_repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null);
|
||||
|
||||
var result = await _sut.UpdateAsync(99, new UpdateBookRequest());
|
||||
|
||||
result.Should().BeNull();
|
||||
await _repo.DidNotReceive().SaveAsync();
|
||||
}
|
||||
|
||||
// ── DTO mapping ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookWithSeries_MapsSeriesNamePositionArc()
|
||||
{
|
||||
var book = BookFactory.Create(id: 1)
|
||||
.WithAuthors((1, "Author"))
|
||||
.WithSeries(seriesId: 10, seriesName: "The Stormlight Archive", position: 1.0, arc: "Book One");
|
||||
_repo.GetByIdAsync(1).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(1);
|
||||
|
||||
result!.Series.Should().NotBeNull();
|
||||
result.Series!.Name.Should().Be("The Stormlight Archive");
|
||||
result.Series.Position.Should().Be(1.0);
|
||||
result.Series.Arc.Should().Be("Book One");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookWithoutSeries_SeriesIsNull()
|
||||
{
|
||||
var book = BookFactory.Create(id: 2).WithAuthors((1, "Author"));
|
||||
_repo.GetByIdAsync(2).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(2);
|
||||
|
||||
result!.Series.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookWithMultipleAuthors_MapsAllAuthors()
|
||||
{
|
||||
var book = BookFactory.Create(id: 3)
|
||||
.WithAuthors((1, "Alice"), (2, "Bob"), (3, "Carol"));
|
||||
_repo.GetByIdAsync(3).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(3);
|
||||
|
||||
result!.Authors.Should().HaveCount(3);
|
||||
result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]);
|
||||
}
|
||||
|
||||
// ── CreateFromHardcoverAsync ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromHardcoverAsync_AlreadyInDb_ReturnsExistingWithoutCallingHardcover()
|
||||
{
|
||||
var existing = BookFactory.Create(id: 7, title: "Existing").WithAuthors((1, "Author"));
|
||||
_repo.FindByHardcoverIdAsync(42).Returns(existing);
|
||||
|
||||
var result = await _sut.CreateFromHardcoverAsync(42);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(7);
|
||||
result.Title.Should().Be("Existing");
|
||||
await _hardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromHardcoverAsync_HardcoverReturnsNull_ReturnsNull()
|
||||
{
|
||||
_repo.FindByHardcoverIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null);
|
||||
_hardcover.GetBookDetailsAsync(99).Returns((HardcoverBookDetails?)null);
|
||||
|
||||
var result = await _sut.CreateFromHardcoverAsync(99);
|
||||
|
||||
result.Should().BeNull();
|
||||
await _repo.DidNotReceive().CreateBookAsync(
|
||||
Arg.Any<PageManager.Api.Data.Models.Book>(),
|
||||
Arg.Any<IReadOnlyList<string>>(),
|
||||
Arg.Any<(string, double, string?)?> ());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromHardcoverAsync_Success_CreatesBookAndReturnsDto()
|
||||
{
|
||||
_repo.FindByHardcoverIdAsync(123).Returns((PageManager.Api.Data.Models.Book?)null);
|
||||
|
||||
var details = new HardcoverBookDetails
|
||||
{
|
||||
Id = 123,
|
||||
Title = "Dune",
|
||||
Year = 1965,
|
||||
Publisher = "Ace Books",
|
||||
Pages = 412,
|
||||
Description = "A sci-fi classic.",
|
||||
Authors = ["Frank Herbert"],
|
||||
Genres = ["Science Fiction"],
|
||||
Isbn = "9780441013593",
|
||||
CoverUrl = "https://example.com/cover.jpg",
|
||||
CoverColor = "#c4a35a",
|
||||
Series = new HardcoverSeriesInfo { Name = "Dune Chronicles", Position = 1.0 },
|
||||
};
|
||||
_hardcover.GetBookDetailsAsync(123).Returns(details);
|
||||
|
||||
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?)?>())
|
||||
.Returns(createdBook);
|
||||
|
||||
var result = await _sut.CreateFromHardcoverAsync(123);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Title.Should().Be("Dune");
|
||||
result.Series.Should().NotBeNull();
|
||||
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<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Data;
|
||||
using PageManager.Api.Data.Models;
|
||||
using PageManager.Api.Services;
|
||||
|
||||
namespace PageManager.Api.Tests.Unit.Services;
|
||||
|
||||
public class ImportServiceTests : IDisposable
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ImportService _sut;
|
||||
|
||||
public ImportServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new AppDbContext(options);
|
||||
_sut = new ImportService(_db);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
// ── Sources ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSourcesAsync_ReturnsMappedDtos()
|
||||
{
|
||||
_db.ImportSources.Add(new ImportSource { Id = "s1", Name = "My Books", Type = ImportSourceType.Folder, Path = "/books", Enabled = true });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = (await _sut.GetSourcesAsync()).ToArray();
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Id.Should().Be("s1");
|
||||
result[0].Name.Should().Be("My Books");
|
||||
result[0].Type.Should().Be("folder");
|
||||
result[0].Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSourceAsync_Found_TogglesEnabled()
|
||||
{
|
||||
_db.ImportSources.Add(new ImportSource { Id = "s1", Name = "Lib", Type = ImportSourceType.Folder, Path = "/", Enabled = true });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _sut.UpdateSourceAsync("s1", false);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeFalse();
|
||||
(await _db.ImportSources.FindAsync("s1"))!.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSourceAsync_NotFound_ReturnsNull()
|
||||
{
|
||||
var result = await _sut.UpdateSourceAsync("missing", true);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Queue ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetQueueAsync_ReturnsMappedDtos()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem
|
||||
{
|
||||
Id = "q1", Filename = "book.epub", SizeBytes = 1024, DownloadedBytes = 512,
|
||||
Status = QueueItemStatus.Downloading, Source = "https://example.com",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = (await _sut.GetQueueAsync()).ToArray();
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Id.Should().Be("q1");
|
||||
result[0].Status.Should().Be("downloading");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Found_RemovesAndReturnsTrue()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _sut.RemoveQueueItemAsync("q1");
|
||||
|
||||
result.Should().BeTrue();
|
||||
_db.ImportQueueItems.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_NotFound_ReturnsFalse()
|
||||
{
|
||||
(await _sut.RemoveQueueItemAsync("missing")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItemAsync_FailedItem_SetsStatusToQueued()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem
|
||||
{
|
||||
Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Failed, Error = "timeout",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _sut.RetryQueueItemAsync("q1");
|
||||
|
||||
result.Should().BeTrue();
|
||||
var item = await _db.ImportQueueItems.FindAsync("q1");
|
||||
item!.Status.Should().Be(QueueItemStatus.Queued);
|
||||
item.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItemAsync_NonFailedItem_ReturnsFalse()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
(await _sut.RetryQueueItemAsync("q1")).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path="compose.yaml" />
|
||||
</Folder>
|
||||
<Project Path="PageManager.Api/PageManager.Api.csproj" />
|
||||
<Project Path="PageManager.Api.Tests/PageManager.Api.Tests.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace PageManager.Api.Api.Dtos;
|
||||
|
||||
public class AuthorDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace PageManager.Api.Api.Dtos;
|
||||
|
||||
public class BookSeriesDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public double Position { get; set; }
|
||||
public string? Arc { get; set; }
|
||||
}
|
||||
|
||||
public class BookDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int? Year { get; set; }
|
||||
public string? Publisher { get; set; }
|
||||
public int? Pages { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string[] Formats { get; set; } = [];
|
||||
public string Color { get; set; } = "#6366f1";
|
||||
public string[] Genres { get; set; } = [];
|
||||
public AuthorDto[] Authors { get; set; } = [];
|
||||
public BookSeriesDto? Series { get; set; }
|
||||
public string? CoverUrl { get; set; }
|
||||
public string? Isbn { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace PageManager.Api.Api.Dtos;
|
||||
|
||||
public class HardcoverBookResult
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string[] Authors { get; set; } = [];
|
||||
public int? Year { get; set; }
|
||||
public string[] Genres { get; set; } = [];
|
||||
}
|
||||
|
||||
public class HardcoverBookDetails
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int? Pages { get; set; }
|
||||
public int? Year { get; set; }
|
||||
public string[] 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 class HardcoverSeriesInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public double Position { get; set; }
|
||||
}
|
||||
|
||||
public class CreateBookFromHardcoverRequest
|
||||
{
|
||||
public int HardcoverId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace PageManager.Api.Api.Dtos;
|
||||
|
||||
public class ImportSourceDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateSourceRequest
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
public class QueueItemDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
public long DownloadedBytes { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace PageManager.Api.Api.Dtos;
|
||||
|
||||
public class UpdateBookRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int? Year { get; set; }
|
||||
public string? Publisher { get; set; }
|
||||
public int? Pages { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string[] Formats { get; set; } = [];
|
||||
public string Color { get; set; } = "#6366f1";
|
||||
public string[] Genres { get; set; } = [];
|
||||
public string? CoverUrl { get; set; }
|
||||
public string? Isbn { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Services;
|
||||
|
||||
namespace PageManager.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class BooksController(IBooksService books) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IEnumerable<BookDto>> GetBooks() =>
|
||||
await books.GetAllAsync();
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<BookDto>> GetBook(int id)
|
||||
{
|
||||
var book = await books.GetByIdAsync(id);
|
||||
return book is null ? NotFound() : Ok(book);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<BookDto>> CreateBook(CreateBookFromHardcoverRequest req)
|
||||
{
|
||||
var book = await books.CreateFromHardcoverAsync(req.HardcoverId);
|
||||
return book is null ? NotFound() : Ok(book);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<BookDto>> UpdateBook(int id, UpdateBookRequest req)
|
||||
{
|
||||
var book = await books.UpdateAsync(id, req);
|
||||
return book is null ? NotFound() : Ok(book);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Services;
|
||||
|
||||
namespace PageManager.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class QueueController(IImportService import) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IEnumerable<QueueItemDto>> GetQueue() =>
|
||||
await import.GetQueueAsync();
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> RemoveItem(string id) =>
|
||||
await import.RemoveQueueItemAsync(id) ? NoContent() : NotFound();
|
||||
|
||||
[HttpPost("{id}/retry")]
|
||||
public async Task<IActionResult> RetryItem(string id) =>
|
||||
await import.RetryQueueItemAsync(id) ? NoContent() : NotFound();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Services;
|
||||
|
||||
namespace PageManager.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class SearchController(IHardcoverService hardcover) : ControllerBase
|
||||
{
|
||||
[HttpGet("books")]
|
||||
public async Task<ActionResult<IEnumerable<HardcoverBookResult>>> SearchBooks([FromQuery] string q)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
return BadRequest("Query parameter 'q' is required.");
|
||||
|
||||
var results = await hardcover.SearchBooksAsync(q);
|
||||
return Ok(results);
|
||||
}
|
||||
}
|
||||
@@ -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 SourcesController(IImportService import) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IEnumerable<ImportSourceDto>> GetSources() =>
|
||||
await import.GetSourcesAsync();
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<ActionResult<ImportSourceDto>> UpdateSource(string id, UpdateSourceRequest req)
|
||||
{
|
||||
var result = await import.UpdateSourceAsync(id, req.Enabled);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Book> Books => Set<Book>();
|
||||
public DbSet<Author> Authors => Set<Author>();
|
||||
public DbSet<Series> Series => Set<Series>();
|
||||
public DbSet<BookAuthor> BookAuthors => Set<BookAuthor>();
|
||||
public DbSet<SeriesEntry> SeriesEntries => Set<SeriesEntry>();
|
||||
public DbSet<ImportSource> ImportSources => Set<ImportSource>();
|
||||
public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder model)
|
||||
{
|
||||
// ── BookAuthor (composite PK) ────────────────────────────────────────
|
||||
model.Entity<BookAuthor>(e =>
|
||||
{
|
||||
e.HasKey(ba => new { ba.BookId, ba.AuthorId });
|
||||
|
||||
e.HasOne(ba => ba.Book)
|
||||
.WithMany(b => b.BookAuthors)
|
||||
.HasForeignKey(ba => ba.BookId);
|
||||
|
||||
e.HasOne(ba => ba.Author)
|
||||
.WithMany(a => a.BookAuthors)
|
||||
.HasForeignKey(ba => ba.AuthorId);
|
||||
|
||||
e.Property(ba => ba.Role)
|
||||
.HasConversion<string>();
|
||||
});
|
||||
|
||||
// ── SeriesEntry (composite PK) ───────────────────────────────────────
|
||||
model.Entity<SeriesEntry>(e =>
|
||||
{
|
||||
e.HasKey(se => new { se.SeriesId, se.BookId });
|
||||
|
||||
e.HasOne(se => se.Series)
|
||||
.WithMany(s => s.Entries)
|
||||
.HasForeignKey(se => se.SeriesId);
|
||||
|
||||
e.HasOne(se => se.Book)
|
||||
.WithMany(b => b.SeriesEntries)
|
||||
.HasForeignKey(se => se.BookId);
|
||||
});
|
||||
|
||||
// ── Series ───────────────────────────────────────────────────────────
|
||||
model.Entity<Series>(e =>
|
||||
{
|
||||
e.Property(s => s.Type)
|
||||
.HasConversion<string>();
|
||||
});
|
||||
|
||||
// ── Book ─────────────────────────────────────────────────────────────
|
||||
model.Entity<Book>(e =>
|
||||
{
|
||||
e.Property(b => b.Title).IsRequired();
|
||||
|
||||
// Formats and Genres are stored as native PostgreSQL text[] columns.
|
||||
e.Property(b => b.Formats).HasColumnType("text[]");
|
||||
e.Property(b => b.Genres).HasColumnType("text[]");
|
||||
|
||||
e.HasIndex(b => b.HardcoverId);
|
||||
e.HasIndex(b => b.Isbn);
|
||||
});
|
||||
|
||||
// ── Author ───────────────────────────────────────────────────────────
|
||||
model.Entity<Author>(e =>
|
||||
{
|
||||
e.Property(a => a.Name).IsRequired();
|
||||
});
|
||||
|
||||
// ── ImportSource ──────────────────────────────────────────────────────
|
||||
model.Entity<ImportSource>(e =>
|
||||
{
|
||||
e.Property(s => s.Id).ValueGeneratedNever();
|
||||
e.Property(s => s.Type).HasConversion<string>();
|
||||
});
|
||||
|
||||
// ── ImportQueueItem ───────────────────────────────────────────────────
|
||||
model.Entity<ImportQueueItem>(e =>
|
||||
{
|
||||
e.Property(i => i.Id).ValueGeneratedNever();
|
||||
e.Property(i => i.Status).HasConversion<string>();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace PageManager.Api.Data.Models;
|
||||
|
||||
public class Author
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public ICollection<BookAuthor> BookAuthors { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace PageManager.Api.Data.Models;
|
||||
|
||||
public class Book
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int? Year { get; set; }
|
||||
public string? Publisher { get; set; }
|
||||
public int? Pages { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Hex color used as cover placeholder when CoverUrl is absent.</summary>
|
||||
public string Color { get; set; } = "#6366f1";
|
||||
public string? CoverUrl { get; set; }
|
||||
|
||||
/// <summary>ISBN-13 sourced from Hardcover.</summary>
|
||||
public string? Isbn { get; set; }
|
||||
|
||||
/// <summary>Hardcover book ID — used to re-fetch metadata without a search round-trip.</summary>
|
||||
public int? HardcoverId { get; set; }
|
||||
|
||||
/// <summary>File formats present in the library (e.g. epub, mobi, pdf). Stored as text[].</summary>
|
||||
public string[] Formats { get; set; } = [];
|
||||
|
||||
/// <summary>Genre tags sourced from Hardcover cached_tags. Stored as text[].</summary>
|
||||
public string[] Genres { get; set; } = [];
|
||||
|
||||
public ICollection<BookAuthor> BookAuthors { get; set; } = [];
|
||||
public ICollection<SeriesEntry> SeriesEntries { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace PageManager.Api.Data.Models;
|
||||
|
||||
public enum AuthorRole { Author, Editor }
|
||||
|
||||
public class BookAuthor
|
||||
{
|
||||
public int BookId { get; set; }
|
||||
public Book Book { get; set; } = null!;
|
||||
|
||||
public int AuthorId { get; set; }
|
||||
public Author Author { get; set; } = null!;
|
||||
|
||||
public AuthorRole Role { get; set; } = AuthorRole.Author;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace PageManager.Api.Data.Models;
|
||||
|
||||
public enum QueueItemStatus { Queued, Downloading, Completed, Failed }
|
||||
|
||||
public class ImportQueueItem
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
public long DownloadedBytes { get; set; }
|
||||
public QueueItemStatus Status { get; set; } = QueueItemStatus.Queued;
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace PageManager.Api.Data.Models;
|
||||
|
||||
public enum ImportSourceType { Folder, Calibre, Opds, Url }
|
||||
|
||||
public class ImportSource
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ImportSourceType Type { get; set; } = ImportSourceType.Folder;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace PageManager.Api.Data.Models;
|
||||
|
||||
public enum SeriesType { SingleAuthor, MultiAuthor }
|
||||
|
||||
public class Series
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public SeriesType Type { get; set; } = SeriesType.SingleAuthor;
|
||||
|
||||
public ICollection<SeriesEntry> Entries { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace PageManager.Api.Data.Models;
|
||||
|
||||
public class SeriesEntry
|
||||
{
|
||||
public int SeriesId { get; set; }
|
||||
public Series Series { get; set; } = null!;
|
||||
|
||||
public int BookId { get; set; }
|
||||
public Book Book { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Fractional position allows inserting novellas between whole-number entries (e.g. 1.5).
|
||||
/// </summary>
|
||||
public double Position { get; set; }
|
||||
|
||||
/// <summary>Sub-arc name within the series (e.g. "The Way of Kings Prime").</summary>
|
||||
public string? Arc { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Data.Repositories;
|
||||
|
||||
public class BooksRepository(AppDbContext db) : IBooksRepository
|
||||
{
|
||||
public Task<IEnumerable<Book>> GetAllAsync() =>
|
||||
db.Books
|
||||
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
|
||||
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
|
||||
.ToListAsync()
|
||||
.ContinueWith(t => (IEnumerable<Book>)t.Result);
|
||||
|
||||
public Task<Book?> GetByIdAsync(int id) =>
|
||||
db.Books
|
||||
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
|
||||
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
|
||||
.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)
|
||||
.FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId);
|
||||
|
||||
public async Task<Book> CreateBookAsync(
|
||||
Book book,
|
||||
IReadOnlyList<string> authorNames,
|
||||
(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 series
|
||||
if (series is { } si)
|
||||
{
|
||||
var s = await db.Series.FirstOrDefaultAsync(x => x.Name == si.name)
|
||||
?? new Series { Name = si.name };
|
||||
if (s.Id == 0) db.Series.Add(s);
|
||||
if (s.Id == 0) await db.SaveChangesAsync();
|
||||
db.SeriesEntries.Add(new SeriesEntry
|
||||
{
|
||||
SeriesId = s.Id,
|
||||
BookId = book.Id,
|
||||
Position = si.position,
|
||||
Arc = si.arc,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return await db.Books
|
||||
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
|
||||
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
|
||||
.FirstAsync(b => b.Id == book.Id);
|
||||
}
|
||||
|
||||
public Task SaveAsync() => db.SaveChangesAsync();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Data.Repositories;
|
||||
|
||||
public interface IBooksRepository
|
||||
{
|
||||
Task<IEnumerable<Book>> GetAllAsync();
|
||||
Task<Book?> GetByIdAsync(int id);
|
||||
Task<Book?> FindByHardcoverIdAsync(int hardcoverId);
|
||||
Task<Book> CreateBookAsync(
|
||||
Book book,
|
||||
IReadOnlyList<string> authorNames,
|
||||
(string name, double position, string? arc)? series);
|
||||
Task SaveAsync();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["PageManager.Api/PageManager.Api.csproj", "PageManager.Api/"]
|
||||
RUN dotnet restore "PageManager.Api/PageManager.Api.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/PageManager.Api"
|
||||
RUN dotnet build "./PageManager.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./PageManager.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "PageManager.Api.dll"]
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
// <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("20260315170137_InitialMigration")]
|
||||
partial class InitialMigration
|
||||
{
|
||||
/// <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.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.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("SeriesEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PageManager.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialMigration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "authors",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
name = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_authors", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "books",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
title = table.Column<string>(type: "text", nullable: false),
|
||||
year = table.Column<int>(type: "integer", nullable: true),
|
||||
publisher = table.Column<string>(type: "text", nullable: true),
|
||||
pages = table.Column<int>(type: "integer", nullable: true),
|
||||
description = table.Column<string>(type: "text", nullable: true),
|
||||
color = table.Column<string>(type: "text", nullable: false),
|
||||
cover_url = table.Column<string>(type: "text", nullable: true),
|
||||
isbn = table.Column<string>(type: "text", nullable: true),
|
||||
hardcover_id = table.Column<int>(type: "integer", nullable: true),
|
||||
formats = table.Column<string[]>(type: "text[]", nullable: false),
|
||||
genres = table.Column<string[]>(type: "text[]", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_books", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "series",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
name = table.Column<string>(type: "text", nullable: false),
|
||||
type = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_series", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "book_authors",
|
||||
columns: table => new
|
||||
{
|
||||
book_id = table.Column<int>(type: "integer", nullable: false),
|
||||
author_id = table.Column<int>(type: "integer", nullable: false),
|
||||
role = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_book_authors", x => new { x.book_id, x.author_id });
|
||||
table.ForeignKey(
|
||||
name: "fk_book_authors_authors_author_id",
|
||||
column: x => x.author_id,
|
||||
principalTable: "authors",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_book_authors_books_book_id",
|
||||
column: x => x.book_id,
|
||||
principalTable: "books",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "series_entries",
|
||||
columns: table => new
|
||||
{
|
||||
series_id = table.Column<int>(type: "integer", nullable: false),
|
||||
book_id = table.Column<int>(type: "integer", nullable: false),
|
||||
position = table.Column<double>(type: "double precision", nullable: false),
|
||||
arc = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_series_entries", x => new { x.series_id, x.book_id });
|
||||
table.ForeignKey(
|
||||
name: "fk_series_entries_books_book_id",
|
||||
column: x => x.book_id,
|
||||
principalTable: "books",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_series_entries_series_series_id",
|
||||
column: x => x.series_id,
|
||||
principalTable: "series",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_book_authors_author_id",
|
||||
table: "book_authors",
|
||||
column: "author_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_books_hardcover_id",
|
||||
table: "books",
|
||||
column: "hardcover_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_books_isbn",
|
||||
table: "books",
|
||||
column: "isbn");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_series_entries_book_id",
|
||||
table: "series_entries",
|
||||
column: "book_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "book_authors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "series_entries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "authors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "books");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "series");
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
// <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("20260315184420_AddImportTables")]
|
||||
partial class AddImportTables
|
||||
{
|
||||
/// <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.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.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("SeriesEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PageManager.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddImportTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
// <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("20260315185207_PendingMigration")]
|
||||
partial class PendingMigration
|
||||
{
|
||||
/// <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.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.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("SeriesEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PageManager.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PendingMigration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "import_queue_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "text", nullable: false),
|
||||
filename = table.Column<string>(type: "text", nullable: false),
|
||||
size_bytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
downloaded_bytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
status = table.Column<string>(type: "text", nullable: false),
|
||||
source = table.Column<string>(type: "text", nullable: false),
|
||||
error = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_import_queue_items", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "import_sources",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "text", nullable: false),
|
||||
name = table.Column<string>(type: "text", nullable: false),
|
||||
type = table.Column<string>(type: "text", nullable: false),
|
||||
path = table.Column<string>(type: "text", nullable: false),
|
||||
enabled = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_import_sources", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "import_queue_items");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "import_sources");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using PageManager.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PageManager.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(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.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.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("SeriesEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.13.8" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
@PageManager.Api_HostAddress = http://localhost:5278
|
||||
|
||||
GET {{PageManager.Api_HostAddress}}/api/books
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
GET {{PageManager.Api_HostAddress}}/api/books/1
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Data;
|
||||
using PageManager.Api.Data.Repositories;
|
||||
using PageManager.Api.Services;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning)
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((ctx, services, cfg) => cfg
|
||||
.ReadFrom.Configuration(ctx.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning)
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"));
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddScoped<IBooksRepository, BooksRepository>();
|
||||
builder.Services.AddScoped<IBooksService, BooksService>();
|
||||
builder.Services.AddHttpClient<IHardcoverService, HardcoverService>();
|
||||
builder.Services.AddScoped<IImportService, ImportService>();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"),
|
||||
o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))
|
||||
.UseSnakeCaseNamingConvention());
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("DevCors", policy =>
|
||||
policy.WithOrigins("http://localhost:5173")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod());
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging(opts =>
|
||||
{
|
||||
opts.MessageTemplate = "{RequestMethod} {RequestPath} → {StatusCode} ({Elapsed:0.0}ms)";
|
||||
});
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
app.UseCors("DevCors");
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "scalar/v1",
|
||||
"applicationUrl": "http://localhost:5278",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "scalar/v1",
|
||||
"applicationUrl": "https://localhost:7196;http://localhost:5278",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Data.Models;
|
||||
using PageManager.Api.Data.Repositories;
|
||||
|
||||
namespace PageManager.Api.Services;
|
||||
|
||||
public class BooksService(IBooksRepository repo, IHardcoverService hardcover) : IBooksService
|
||||
{
|
||||
public async Task<IEnumerable<BookDto>> GetAllAsync()
|
||||
{
|
||||
var books = await repo.GetAllAsync();
|
||||
return books.Select(ToDto);
|
||||
}
|
||||
|
||||
public async Task<BookDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var book = await repo.GetByIdAsync(id);
|
||||
return book is null ? null : ToDto(book);
|
||||
}
|
||||
|
||||
public async Task<BookDto?> UpdateAsync(int id, UpdateBookRequest req)
|
||||
{
|
||||
var book = await repo.GetByIdAsync(id);
|
||||
if (book is null) return null;
|
||||
|
||||
book.Title = req.Title;
|
||||
book.Year = req.Year;
|
||||
book.Publisher = req.Publisher;
|
||||
book.Pages = req.Pages;
|
||||
book.Description = req.Description;
|
||||
book.Formats = req.Formats;
|
||||
book.Color = req.Color;
|
||||
book.Genres = req.Genres;
|
||||
book.CoverUrl = req.CoverUrl;
|
||||
book.Isbn = req.Isbn;
|
||||
book.HardcoverId = req.HardcoverId;
|
||||
|
||||
await repo.SaveAsync();
|
||||
return ToDto(book);
|
||||
}
|
||||
|
||||
public async Task<BookDto?> CreateFromHardcoverAsync(int hardcoverId)
|
||||
{
|
||||
// Idempotent: return existing book if already in DB
|
||||
var existing = await repo.FindByHardcoverIdAsync(hardcoverId);
|
||||
if (existing is not null) return ToDto(existing);
|
||||
|
||||
var details = await hardcover.GetBookDetailsAsync(hardcoverId);
|
||||
if (details is null) return null;
|
||||
|
||||
var color = details.CoverColor is { Length: > 0 } c && c.StartsWith('#') ? c : "#6366f1";
|
||||
|
||||
var book = new Book
|
||||
{
|
||||
Title = details.Title,
|
||||
Year = details.Year,
|
||||
Publisher = details.Publisher,
|
||||
Pages = details.Pages,
|
||||
Description = details.Description,
|
||||
Formats = [],
|
||||
Color = color,
|
||||
Genres = details.Genres,
|
||||
CoverUrl = details.CoverUrl,
|
||||
Isbn = details.Isbn,
|
||||
HardcoverId = details.Id,
|
||||
};
|
||||
|
||||
(string name, double position, string? arc)? seriesArg = details.Series is { } s
|
||||
? (s.Name, s.Position, (string?)null) : null;
|
||||
|
||||
var created = await repo.CreateBookAsync(book, details.Authors, seriesArg);
|
||||
return ToDto(created);
|
||||
}
|
||||
|
||||
private static BookDto ToDto(Book book)
|
||||
{
|
||||
var entry = book.SeriesEntries.FirstOrDefault();
|
||||
return new BookDto
|
||||
{
|
||||
Id = book.Id,
|
||||
Title = book.Title,
|
||||
Year = book.Year,
|
||||
Publisher = book.Publisher,
|
||||
Pages = book.Pages,
|
||||
Description = book.Description,
|
||||
Formats = book.Formats,
|
||||
Color = book.Color,
|
||||
Genres = book.Genres,
|
||||
Authors = book.BookAuthors
|
||||
.Select(ba => new AuthorDto { Id = ba.Author.Id, Name = ba.Author.Name })
|
||||
.ToArray(),
|
||||
Series = entry is null ? null : new BookSeriesDto
|
||||
{
|
||||
Name = entry.Series.Name,
|
||||
Position = entry.Position,
|
||||
Arc = entry.Arc,
|
||||
},
|
||||
CoverUrl = book.CoverUrl,
|
||||
Isbn = book.Isbn,
|
||||
HardcoverId = book.HardcoverId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
|
||||
namespace PageManager.Api.Services;
|
||||
|
||||
public class HardcoverService(HttpClient http, IConfiguration config, ILogger<HardcoverService> logger) : IHardcoverService
|
||||
{
|
||||
private const string Endpoint = "https://api.hardcover.app/v1/graphql";
|
||||
|
||||
private const string SearchQuery = """
|
||||
query SearchBooks($q: String!) {
|
||||
search(query: $q, query_type: "Book", per_page: 15, page: 1) {
|
||||
results
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DetailsQuery = """
|
||||
query GetBookDetails($id: Int!) {
|
||||
books(where: {id: {_eq: $id}}, limit: 1) {
|
||||
id
|
||||
title
|
||||
description
|
||||
pages
|
||||
release_year
|
||||
cached_contributors
|
||||
cached_tags
|
||||
book_series(order_by: {position: asc}, limit: 1) {
|
||||
position
|
||||
series { id name }
|
||||
}
|
||||
editions(order_by: {users_count: desc}, limit: 3) {
|
||||
isbn_13
|
||||
publisher { name }
|
||||
image { url color }
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
public async Task<IEnumerable<HardcoverBookResult>> SearchBooksAsync(string query)
|
||||
{
|
||||
using var doc = await SendQueryAsync(SearchQuery, new { q = query });
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("search", out var search) ||
|
||||
!search.TryGetProperty("results", out var results))
|
||||
return [];
|
||||
|
||||
// results is a Typesense JSON blob — may be embedded object or string
|
||||
if (results.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
using var inner = JsonDocument.Parse(results.GetString()!);
|
||||
return inner.RootElement.TryGetProperty("hits", out var h) ? ParseHits(h) : [];
|
||||
}
|
||||
|
||||
return results.TryGetProperty("hits", out var hits) ? ParseHits(hits) : [];
|
||||
}
|
||||
|
||||
public async Task<HardcoverBookDetails?> GetBookDetailsAsync(int hardcoverId)
|
||||
{
|
||||
logger.LogInformation("Fetching Hardcover book details for id={Id}", hardcoverId);
|
||||
|
||||
using var doc = await SendQueryAsync(DetailsQuery, new { id = hardcoverId });
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data))
|
||||
{
|
||||
logger.LogWarning("Hardcover response missing 'data' for id={Id}. Response: {Json}",
|
||||
hardcoverId, doc.RootElement.GetRawText());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("books", out var books))
|
||||
{
|
||||
logger.LogWarning("Hardcover response missing 'books' for id={Id}. Data: {Json}",
|
||||
hardcoverId, data.GetRawText());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (books.GetArrayLength() == 0)
|
||||
{
|
||||
logger.LogWarning("Hardcover returned empty books array for id={Id}", hardcoverId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var details = ParseBookDetails(books[0]);
|
||||
if (details.Authors.Length == 0)
|
||||
logger.LogWarning("No authors parsed for book id={Id}. cached_contributors: {Raw}",
|
||||
hardcoverId,
|
||||
books[0].TryGetProperty("cached_contributors", out var cc) ? cc.GetRawText() : "missing");
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
// ── Parsing ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<HardcoverBookResult> ParseHits(JsonElement hitsEl)
|
||||
{
|
||||
var list = new List<HardcoverBookResult>();
|
||||
foreach (var hit in hitsEl.EnumerateArray())
|
||||
{
|
||||
if (!hit.TryGetProperty("document", out var d)) continue;
|
||||
|
||||
var id = 0;
|
||||
if (d.TryGetProperty("id", out var idEl))
|
||||
id = idEl.ValueKind == JsonValueKind.Number
|
||||
? idEl.GetInt32()
|
||||
: int.TryParse(idEl.GetString(), out var p) ? p : 0;
|
||||
if (id == 0) continue;
|
||||
|
||||
var title = d.TryGetProperty("title", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
|
||||
var authors = new List<string>();
|
||||
if (d.TryGetProperty("author_names", out var aNamesEl))
|
||||
foreach (var a in aNamesEl.EnumerateArray())
|
||||
if (a.GetString() is string n) authors.Add(n);
|
||||
|
||||
int? year = d.TryGetProperty("release_year", out var yEl) && yEl.ValueKind == JsonValueKind.Number
|
||||
? yEl.GetInt32() : null;
|
||||
|
||||
var genres = new List<string>();
|
||||
if (d.TryGetProperty("genres", out var gEl))
|
||||
foreach (var g in gEl.EnumerateArray())
|
||||
if (g.GetString() is string gn) genres.Add(gn);
|
||||
|
||||
list.Add(new HardcoverBookResult
|
||||
{
|
||||
Id = id,
|
||||
Title = title,
|
||||
Authors = [.. authors],
|
||||
Year = year,
|
||||
Genres = [.. genres],
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static HardcoverBookDetails ParseBookDetails(JsonElement book)
|
||||
{
|
||||
var id = book.TryGetProperty("id", out var idEl) ? idEl.GetInt32() : 0;
|
||||
var title = book.TryGetProperty("title", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
var desc = book.TryGetProperty("description", out var dEl) && dEl.ValueKind != JsonValueKind.Null ? dEl.GetString() : null;
|
||||
int? pages = book.TryGetProperty("pages", out var pEl) && pEl.ValueKind == JsonValueKind.Number ? pEl.GetInt32() : null;
|
||||
int? year = book.TryGetProperty("release_year",out var yEl) && yEl.ValueKind == JsonValueKind.Number ? yEl.GetInt32() : null;
|
||||
|
||||
var authors = ParseCachedContributors(book);
|
||||
var genres = ParseCachedTags(book);
|
||||
var series = ParseBookSeries(book);
|
||||
var (isbn, publisher, coverUrl, coverColor) = ParseEditions(book);
|
||||
|
||||
return new HardcoverBookDetails
|
||||
{
|
||||
Id = id,
|
||||
Title = title,
|
||||
Description = desc,
|
||||
Pages = pages,
|
||||
Year = year,
|
||||
Authors = [.. authors],
|
||||
Genres = [.. genres],
|
||||
Isbn = isbn,
|
||||
Publisher = publisher,
|
||||
CoverUrl = coverUrl,
|
||||
CoverColor = coverColor,
|
||||
Series = series,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParseCachedContributors(JsonElement book)
|
||||
{
|
||||
var authors = new List<string>();
|
||||
if (!book.TryGetProperty("cached_contributors", out var el)) return authors;
|
||||
|
||||
if (el.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (el.GetString() is not string s) return authors;
|
||||
using var inner = JsonDocument.Parse(s);
|
||||
ExtractContributorNames(inner.RootElement, authors);
|
||||
return authors;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ParseCachedTags(JsonElement book)
|
||||
{
|
||||
var tags = new List<string>();
|
||||
if (!book.TryGetProperty("cached_tags", out var el)) return tags;
|
||||
|
||||
if (el.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (el.GetString() is not string s) return tags;
|
||||
using var inner = JsonDocument.Parse(s);
|
||||
ExtractTagNames(inner.RootElement, tags);
|
||||
return tags;
|
||||
}
|
||||
|
||||
ExtractTagNames(el, tags);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private static void ExtractTagNames(JsonElement el, List<string> tags)
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Array) return;
|
||||
foreach (var t in el.EnumerateArray())
|
||||
if (t.TryGetProperty("tag", out var n) && n.GetString() is string tag)
|
||||
tags.Add(tag);
|
||||
}
|
||||
|
||||
private static HardcoverSeriesInfo? ParseBookSeries(JsonElement book)
|
||||
{
|
||||
if (!book.TryGetProperty("book_series", out var arr) || arr.GetArrayLength() == 0)
|
||||
return null;
|
||||
|
||||
var entry = arr[0];
|
||||
var position = entry.TryGetProperty("position", out var posEl) && posEl.ValueKind == JsonValueKind.Number
|
||||
? posEl.GetDouble() : 1.0;
|
||||
string? seriesName = null;
|
||||
if (entry.TryGetProperty("series", out var seriesEl) && seriesEl.ValueKind != JsonValueKind.Null)
|
||||
seriesName = seriesEl.TryGetProperty("name", out var snEl) ? snEl.GetString() : null;
|
||||
|
||||
return seriesName is null ? null : new HardcoverSeriesInfo { Name = seriesName, Position = position };
|
||||
}
|
||||
|
||||
private static (string? isbn, string? publisher, string? coverUrl, string? coverColor) ParseEditions(JsonElement book)
|
||||
{
|
||||
string? isbn = null, publisher = null, coverUrl = null, coverColor = null;
|
||||
|
||||
if (!book.TryGetProperty("editions", out var editions)) return (isbn, publisher, coverUrl, coverColor);
|
||||
|
||||
foreach (var ed in editions.EnumerateArray())
|
||||
{
|
||||
if (isbn is null && ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null)
|
||||
isbn = isbnEl.GetString();
|
||||
|
||||
if (publisher is null && 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return (isbn, publisher, coverUrl, coverColor);
|
||||
}
|
||||
|
||||
// ── HTTP ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<JsonDocument> SendQueryAsync(string query, object? variables = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, Endpoint)
|
||||
{
|
||||
Content = JsonContent.Create(new { query, variables })
|
||||
};
|
||||
|
||||
var apiKey = config["Hardcover:ApiKey"];
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
var response = await http.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using PageManager.Api.Api.Dtos;
|
||||
|
||||
namespace PageManager.Api.Services;
|
||||
|
||||
public interface IBooksService
|
||||
{
|
||||
Task<IEnumerable<BookDto>> GetAllAsync();
|
||||
Task<BookDto?> GetByIdAsync(int id);
|
||||
Task<BookDto?> UpdateAsync(int id, UpdateBookRequest req);
|
||||
/// <summary>
|
||||
/// Fetches full metadata from Hardcover and persists the book.
|
||||
/// Returns the existing book if hardcoverId already exists in the DB.
|
||||
/// Returns null if the Hardcover API does not recognise the id.
|
||||
/// </summary>
|
||||
Task<BookDto?> CreateFromHardcoverAsync(int hardcoverId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using PageManager.Api.Api.Dtos;
|
||||
|
||||
namespace PageManager.Api.Services;
|
||||
|
||||
public interface IHardcoverService
|
||||
{
|
||||
Task<IEnumerable<HardcoverBookResult>> SearchBooksAsync(string query);
|
||||
Task<HardcoverBookDetails?> GetBookDetailsAsync(int hardcoverId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using PageManager.Api.Api.Dtos;
|
||||
|
||||
namespace PageManager.Api.Services;
|
||||
|
||||
public interface IImportService
|
||||
{
|
||||
Task<IEnumerable<ImportSourceDto>> GetSourcesAsync();
|
||||
Task<ImportSourceDto?> UpdateSourceAsync(string id, bool enabled);
|
||||
Task<IEnumerable<QueueItemDto>> GetQueueAsync();
|
||||
Task<bool> RemoveQueueItemAsync(string id);
|
||||
Task<bool> RetryQueueItemAsync(string id);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Data;
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Services;
|
||||
|
||||
public class ImportService(AppDbContext db) : IImportService
|
||||
{
|
||||
public async Task<IEnumerable<ImportSourceDto>> GetSourcesAsync() =>
|
||||
(await db.ImportSources.ToListAsync()).Select(ToDto);
|
||||
|
||||
public async Task<ImportSourceDto?> UpdateSourceAsync(string id, bool enabled)
|
||||
{
|
||||
var source = await db.ImportSources.FindAsync(id);
|
||||
if (source is null) return null;
|
||||
|
||||
source.Enabled = enabled;
|
||||
await db.SaveChangesAsync();
|
||||
return ToDto(source);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<QueueItemDto>> GetQueueAsync() =>
|
||||
(await db.ImportQueueItems
|
||||
.OrderByDescending(i => i.Status == QueueItemStatus.Downloading)
|
||||
.ThenByDescending(i => i.Status == QueueItemStatus.Queued)
|
||||
.ToListAsync())
|
||||
.Select(ToDto);
|
||||
|
||||
public async Task<bool> RemoveQueueItemAsync(string id)
|
||||
{
|
||||
var item = await db.ImportQueueItems.FindAsync(id);
|
||||
if (item is null) return false;
|
||||
|
||||
db.ImportQueueItems.Remove(item);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RetryQueueItemAsync(string id)
|
||||
{
|
||||
var item = await db.ImportQueueItems.FindAsync(id);
|
||||
if (item is null || item.Status != QueueItemStatus.Failed) return false;
|
||||
|
||||
item.Status = QueueItemStatus.Queued;
|
||||
item.Error = null;
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ImportSourceDto ToDto(ImportSource s) => new()
|
||||
{
|
||||
Id = s.Id,
|
||||
Name = s.Name,
|
||||
Type = s.Type.ToString().ToLowerInvariant(),
|
||||
Path = s.Path,
|
||||
Enabled = s.Enabled,
|
||||
};
|
||||
|
||||
private static QueueItemDto ToDto(ImportQueueItem i) => new()
|
||||
{
|
||||
Id = i.Id,
|
||||
Filename = i.Filename,
|
||||
SizeBytes = i.SizeBytes,
|
||||
DownloadedBytes = i.DownloadedBytes,
|
||||
Status = i.Status.ToString().ToLowerInvariant(),
|
||||
Source = i.Source,
|
||||
Error = i.Error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Hardcover": {
|
||||
"ApiKey": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
pagemanager.api:
|
||||
image: pagemanager.api
|
||||
build:
|
||||
context: .
|
||||
dockerfile: PageManager.Api/Dockerfile
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# In development, leave VITE_API_URL unset — Vite proxies /api to http://localhost:5278
|
||||
# VITE_API_URL=http://localhost:5278/api
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
coverage/
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS final
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy /api to the backend
|
||||
location /api/ {
|
||||
proxy_pass http://pagemanager.api:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# SPA fallback — all other routes serve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+3844
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "pagemanager-web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"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",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import Sidebar from './components/Sidebar/Sidebar'
|
||||
import Library from './pages/Library/Library'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fetchBooks, fetchBook, updateBook } from '../books'
|
||||
|
||||
const mockBook = {
|
||||
id: 1,
|
||||
title: 'Dune',
|
||||
year: 1965,
|
||||
publisher: null,
|
||||
pages: null,
|
||||
description: null,
|
||||
formats: ['epub'] as const,
|
||||
color: '#6366f1',
|
||||
genres: ['Science Fiction'],
|
||||
authors: [{ id: 1, name: 'Frank Herbert' }],
|
||||
coverUrl: null,
|
||||
isbn: null,
|
||||
hardcoverId: null,
|
||||
}
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? 'OK' : 'Not Found',
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch([mockBook]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('fetchBooks', () => {
|
||||
it('calls GET /api/books', async () => {
|
||||
await fetchBooks()
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/books'),
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the parsed array', async () => {
|
||||
const result = await fetchBooks()
|
||||
expect(result).toEqual([mockBook])
|
||||
})
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(null, false, 500))
|
||||
await expect(fetchBooks()).rejects.toThrow('500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchBook', () => {
|
||||
it('calls GET /api/books/:id', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockBook))
|
||||
await fetchBook(42)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/books/42'),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(null, false, 404))
|
||||
await expect(fetchBook(99)).rejects.toThrow('404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateBook', () => {
|
||||
it('calls PUT /api/books/:id with JSON body', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockBook))
|
||||
const patch = { title: 'New Title' }
|
||||
await updateBook(1, patch)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/books/1'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the updated book', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockBook))
|
||||
const result = await updateBook(1, { title: 'New Title' })
|
||||
expect(result).toEqual(mockBook)
|
||||
})
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(null, false, 404))
|
||||
await expect(updateBook(99, {})).rejects.toThrow('404')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../importQueue'
|
||||
import type { QueueItem, ImportSource } from '../../types'
|
||||
|
||||
const mockQueue: QueueItem[] = [
|
||||
{ id: 'q1', filename: 'book.epub', sizeBytes: 1024, downloadedBytes: 512, status: 'downloading', source: 'https://example.com' },
|
||||
]
|
||||
|
||||
const mockSources: ImportSource[] = [
|
||||
{ id: 's1', name: 'My Library', type: 'folder', path: '/books', enabled: true },
|
||||
]
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? 'OK' : 'Not Found',
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('fetchQueue', () => {
|
||||
it('calls GET /api/queue', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockQueue))
|
||||
await fetchQueue()
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/queue'), expect.any(Object))
|
||||
})
|
||||
|
||||
it('returns queue items', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockQueue))
|
||||
expect(await fetchQueue()).toEqual(mockQueue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSources', () => {
|
||||
it('calls GET /api/sources', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockSources))
|
||||
await fetchSources()
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/sources'), expect.any(Object))
|
||||
})
|
||||
|
||||
it('returns sources', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockSources))
|
||||
expect(await fetchSources()).toEqual(mockSources)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryQueueItem', () => {
|
||||
it('calls POST /api/queue/:id/retry', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(null))
|
||||
await retryQueueItem('q1')
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/queue/q1/retry'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeQueueItem', () => {
|
||||
it('calls DELETE /api/queue/:id', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(null))
|
||||
await removeQueueItem('q1')
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/queue/q1'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSource', () => {
|
||||
it('calls PATCH /api/sources/:id with enabled flag', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch({ ...mockSources[0], enabled: false }))
|
||||
await updateSource('s1', false)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/sources/s1'),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { searchHardcover, addBookFromHardcover } from '../search'
|
||||
import type { HardcoverSearchResult, Book } from '../../types'
|
||||
|
||||
const mockResults: HardcoverSearchResult[] = [
|
||||
{ id: 1, title: 'Dune', authors: ['Frank Herbert'], year: 1965, genres: ['Science Fiction'] },
|
||||
]
|
||||
|
||||
const mockBook: Book = {
|
||||
id: 10,
|
||||
title: 'Dune',
|
||||
year: 1965,
|
||||
publisher: 'Ace Books',
|
||||
pages: 412,
|
||||
description: null,
|
||||
formats: [],
|
||||
color: '#6366f1',
|
||||
genres: ['Science Fiction'],
|
||||
authors: [{ id: 1, name: 'Frank Herbert' }],
|
||||
coverUrl: null,
|
||||
isbn: null,
|
||||
hardcoverId: 1,
|
||||
}
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? 'OK' : 'Not Found',
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('searchHardcover', () => {
|
||||
it('calls GET /api/search/books with encoded query', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockResults))
|
||||
await searchHardcover('dune frank')
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/search/books?q=dune%20frank'),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns parsed results array', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockResults))
|
||||
const result = await searchHardcover('dune')
|
||||
expect(result).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(null, false, 500))
|
||||
await expect(searchHardcover('dune')).rejects.toThrow('500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addBookFromHardcover', () => {
|
||||
it('calls POST /api/books with hardcoverId in body', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockBook))
|
||||
await addBookFromHardcover(42)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/books'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ hardcoverId: 42 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the created book', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(mockBook))
|
||||
const result = await addBookFromHardcover(1)
|
||||
expect(result).toEqual(mockBook)
|
||||
})
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(null, false, 404))
|
||||
await expect(addBookFromHardcover(999)).rejects.toThrow('404')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Book } from '../types'
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchBooks(): Promise<Book[]> {
|
||||
return api.get<Book[]>('/books')
|
||||
}
|
||||
|
||||
export function fetchBook(id: number): Promise<Book> {
|
||||
return api.get<Book>(`/books/${id}`)
|
||||
}
|
||||
|
||||
export function updateBook(id: number, patch: Partial<Book>): Promise<Book> {
|
||||
return api.put<Book>(`/books/${id}`, patch)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api'
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { QueueItem, ImportSource } from '../types'
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchQueue(): Promise<QueueItem[]> {
|
||||
return api.get<QueueItem[]>('/queue')
|
||||
}
|
||||
|
||||
export function fetchSources(): Promise<ImportSource[]> {
|
||||
return api.get<ImportSource[]>('/sources')
|
||||
}
|
||||
|
||||
export function retryQueueItem(id: string): Promise<void> {
|
||||
return api.post<void>(`/queue/${id}/retry`, {})
|
||||
}
|
||||
|
||||
export function removeQueueItem(id: string): Promise<void> {
|
||||
return api.delete(`/queue/${id}`)
|
||||
}
|
||||
|
||||
export function updateSource(id: string, enabled: boolean): Promise<ImportSource> {
|
||||
return api.patch<ImportSource>(`/sources/${id}`, { enabled })
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Book, HardcoverSearchResult } from '../types'
|
||||
import { api } from './client'
|
||||
|
||||
export function searchHardcover(q: string): Promise<HardcoverSearchResult[]> {
|
||||
return api.get<HardcoverSearchResult[]>(`/search/books?q=${encodeURIComponent(q)}`)
|
||||
}
|
||||
|
||||
export function addBookFromHardcover(hardcoverId: number): Promise<Book> {
|
||||
return api.post<Book>('/books', { hardcoverId })
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Book, HardcoverSearchResult } from '../../types'
|
||||
import { searchHardcover, addBookFromHardcover } from '../../api/search'
|
||||
import s from './AddBookDialog.module.css'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
onAdded: (book: Book) => void
|
||||
}
|
||||
|
||||
export default function AddBookDialog({ onClose, onAdded }: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<HardcoverSearchResult[]>([])
|
||||
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)
|
||||
|
||||
useEffect(() => { inputRef.current?.focus() }, [])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setResults([]); return }
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(true)
|
||||
searchHardcover(query)
|
||||
.then(setResults)
|
||||
.catch(() => setResults([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, 400)
|
||||
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)
|
||||
try {
|
||||
const book = await addBookFromHardcover(result.id)
|
||||
setAdded(prev => new Set(prev).add(result.id))
|
||||
onAdded(book)
|
||||
} finally {
|
||||
setAdding(null)
|
||||
}
|
||||
}
|
||||
|
||||
const showEmpty = !loading && query.trim() && results.length === 0
|
||||
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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className={s.results}>
|
||||
{loading && (
|
||||
<div className={s.spinner}>
|
||||
<span className="material-symbols-outlined">progress_activity</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHint && (
|
||||
<p className={s.empty}>Start typing to search Hardcover</p>
|
||||
)}
|
||||
|
||||
{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
|
||||
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/* 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);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: box-shadow 200ms cubic-bezier(.2,0,0,1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
}
|
||||
|
||||
.card.selected {
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
outline: 2px solid var(--md-sys-color-primary);
|
||||
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);
|
||||
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); }
|
||||
|
||||
.cover {
|
||||
aspect-ratio: 2/3;
|
||||
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: 1.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,.3);
|
||||
letter-spacing: .04em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.seriesPill {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,.45);
|
||||
color: #fff;
|
||||
font: var(--md-sys-typescale-label-small);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 8px 10px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--md-sys-typescale-title-small);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.author {
|
||||
font: var(--md-sys-typescale-body-small);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Book } from '../../types'
|
||||
import s from './BookCard.module.css'
|
||||
|
||||
interface Props {
|
||||
book: Book
|
||||
onClick: (book: Book) => void
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export default function BookCard({ book, onClick, selected }: Props) {
|
||||
const initials = book.title
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map(w => w[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${s.card} ${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>
|
||||
}
|
||||
{book.series && (
|
||||
<span className={s.seriesPill}>#{book.series.position}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* MD3 state layer */}
|
||||
<div className={s.stateLayer} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import BookCard from '../BookCard'
|
||||
import type { Book } from '../../../types'
|
||||
|
||||
function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Dune',
|
||||
year: 1965,
|
||||
publisher: null,
|
||||
pages: null,
|
||||
description: null,
|
||||
formats: ['epub'],
|
||||
color: '#6366f1',
|
||||
genres: ['Science Fiction'],
|
||||
authors: [{ id: 1, name: 'Frank Herbert' }],
|
||||
coverUrl: null,
|
||||
isbn: null,
|
||||
hardcoverId: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('BookCard', () => {
|
||||
it('renders the title', () => {
|
||||
render(<BookCard book={makeBook()} onClick={vi.fn()} />)
|
||||
expect(screen.getByText('Dune')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the author name', () => {
|
||||
render(<BookCard book={makeBook()} onClick={vi.fn()} />)
|
||||
expect(screen.getByText('Frank Herbert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders format chips', () => {
|
||||
render(<BookCard book={makeBook({ formats: ['epub', 'mobi'] })} onClick={vi.fn()} />)
|
||||
expect(screen.getByText('EPUB')).toBeInTheDocument()
|
||||
expect(screen.getByText('MOBI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders series position pill when series exists', () => {
|
||||
render(<BookCard book={makeBook({ series: { name: 'Dune Chronicles', position: 1 } })} onClick={vi.fn()} />)
|
||||
expect(screen.getByText('#1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render series position pill when series is absent', () => {
|
||||
render(<BookCard book={makeBook({ series: undefined })} onClick={vi.fn()} />)
|
||||
expect(screen.queryByText(/#\d/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClick with the book when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const book = makeBook()
|
||||
const onClick = vi.fn()
|
||||
render(<BookCard book={book} onClick={onClick} />)
|
||||
await user.click(screen.getByRole('article'))
|
||||
expect(onClick).toHaveBeenCalledWith(book)
|
||||
})
|
||||
|
||||
it('applies selected styling when selected is true', () => {
|
||||
const { container } = render(<BookCard book={makeBook()} onClick={vi.fn()} selected={true} />)
|
||||
const article = container.querySelector('article')
|
||||
expect(article?.className).toContain('selected')
|
||||
})
|
||||
|
||||
it('does not apply selected styling when selected is false', () => {
|
||||
const { container } = render(<BookCard book={makeBook()} onClick={vi.fn()} selected={false} />)
|
||||
const article = container.querySelector('article')
|
||||
expect(article?.className).not.toContain('selected')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
/* 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; }
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Book } from '../../types'
|
||||
import s from './DetailPanel.module.css'
|
||||
|
||||
interface Props {
|
||||
book: Book | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function DetailPanel({ book, onClose }: 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||||
return (
|
||||
<div className={s.stat}>
|
||||
<span className={`material-symbols-outlined ${s.statIcon}`}>{icon}</span>
|
||||
<div>
|
||||
<p className={s.statLabel}>{label}</p>
|
||||
<p className={s.statValue}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
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
|
||||
}
|
||||
|
||||
export default function MetadataForm({ book, onSave }: Props) {
|
||||
const [form, setForm] = useState<FormState>(() => toForm(book))
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setForm(toForm(book))
|
||||
setSaved(false)
|
||||
}, [book.id])
|
||||
|
||||
const set = (field: keyof FormState) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm(f => ({ ...f, [field]: e.target.value }))
|
||||
|
||||
function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
onSave({
|
||||
title: form.title,
|
||||
publisher: form.publisher || null,
|
||||
year: form.year ? Number(form.year) : null,
|
||||
pages: form.pages ? Number(form.pages) : null,
|
||||
genres: form.genres.split(',').map(g => g.trim()).filter(Boolean),
|
||||
description: form.description || null,
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
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')} />
|
||||
</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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import MetadataForm from '../MetadataForm'
|
||||
import type { Book } from '../../../types'
|
||||
|
||||
function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Dune',
|
||||
year: 1965,
|
||||
publisher: 'Chilton Books',
|
||||
pages: 412,
|
||||
description: 'A sci-fi classic.',
|
||||
formats: ['epub'],
|
||||
color: '#6366f1',
|
||||
genres: ['Science Fiction'],
|
||||
authors: [{ id: 1, name: 'Frank Herbert' }],
|
||||
coverUrl: null,
|
||||
isbn: null,
|
||||
hardcoverId: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MetadataForm', () => {
|
||||
it('renders the title field with book title', () => {
|
||||
render(<MetadataForm book={makeBook()} onSave={vi.fn()} />)
|
||||
expect(screen.getByDisplayValue('Dune')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the author field joined by comma', () => {
|
||||
render(<MetadataForm book={makeBook({ authors: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] })} onSave={vi.fn()} />)
|
||||
expect(screen.getByDisplayValue('Alice, Bob')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSave with patch when form is submitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
render(<MetadataForm book={makeBook()} onSave={onSave} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { toForm } from '../utils'
|
||||
import type { Book } from '../../../types'
|
||||
|
||||
function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Default Title',
|
||||
year: null,
|
||||
publisher: null,
|
||||
pages: null,
|
||||
description: null,
|
||||
formats: [],
|
||||
color: '#6366f1',
|
||||
genres: [],
|
||||
authors: [],
|
||||
series: undefined,
|
||||
coverUrl: null,
|
||||
isbn: null,
|
||||
hardcoverId: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('toForm', () => {
|
||||
it('maps title directly', () => {
|
||||
const result = toForm(makeBook({ title: 'Dune' }))
|
||||
expect(result.title).toBe('Dune')
|
||||
})
|
||||
|
||||
it('joins multiple authors with ", "', () => {
|
||||
const result = toForm(makeBook({
|
||||
authors: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
|
||||
}))
|
||||
expect(result.authors).toBe('Alice, Bob')
|
||||
})
|
||||
|
||||
it('maps nullable year to empty string when absent', () => {
|
||||
expect(toForm(makeBook({ year: null })).year).toBe('')
|
||||
})
|
||||
|
||||
it('maps year to string when present', () => {
|
||||
expect(toForm(makeBook({ year: 2001 })).year).toBe('2001')
|
||||
})
|
||||
|
||||
it('maps nullable publisher to empty string', () => {
|
||||
expect(toForm(makeBook({ publisher: null })).publisher).toBe('')
|
||||
})
|
||||
|
||||
it('maps nullable pages to empty string', () => {
|
||||
expect(toForm(makeBook({ pages: null })).pages).toBe('')
|
||||
})
|
||||
|
||||
it('maps pages to string when present', () => {
|
||||
expect(toForm(makeBook({ pages: 412 })).pages).toBe('412')
|
||||
})
|
||||
|
||||
it('maps nullable description to empty string', () => {
|
||||
expect(toForm(makeBook({ description: null })).description).toBe('')
|
||||
})
|
||||
|
||||
it('joins genres with ", "', () => {
|
||||
const result = toForm(makeBook({ genres: ['Fantasy', 'Adventure'] }))
|
||||
expect(result.genres).toBe('Fantasy, Adventure')
|
||||
})
|
||||
|
||||
it('maps series name and position when present', () => {
|
||||
const result = toForm(makeBook({ series: { name: 'Mistborn', position: 1, arc: undefined } }))
|
||||
expect(result.series).toBe('Mistborn')
|
||||
expect(result.seriesPosition).toBe('1')
|
||||
})
|
||||
|
||||
it('maps series fields to empty strings when series absent', () => {
|
||||
const result = toForm(makeBook({ series: undefined }))
|
||||
expect(result.series).toBe('')
|
||||
expect(result.seriesPosition).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Book } from '../../types'
|
||||
|
||||
export interface FormState {
|
||||
title: string
|
||||
authors: string
|
||||
series: string
|
||||
seriesPosition: string
|
||||
publisher: string
|
||||
year: string
|
||||
genres: string
|
||||
pages: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function toForm(book: Book): FormState {
|
||||
return {
|
||||
title: book.title,
|
||||
authors: book.authors.map(a => a.name).join(', '),
|
||||
series: book.series?.name ?? '',
|
||||
seriesPosition: book.series ? String(book.series.position) : '',
|
||||
publisher: book.publisher ?? '',
|
||||
year: book.year ? String(book.year) : '',
|
||||
genres: book.genres.join(', '),
|
||||
pages: book.pages ? String(book.pages) : '',
|
||||
description: book.description ?? '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* 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; }
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { QueueItem as IQueueItem } from '../../types'
|
||||
import { formatBytes } from './utils'
|
||||
import s from './QueueItem.module.css'
|
||||
|
||||
interface Props {
|
||||
item: IQueueItem
|
||||
onRetry: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<IQueueItem['status'], string> = {
|
||||
queued: 'schedule',
|
||||
downloading: 'downloading',
|
||||
completed: 'check_circle',
|
||||
failed: 'error',
|
||||
}
|
||||
|
||||
export default function QueueItem({ item, onRetry, onRemove }: Props) {
|
||||
const pct = item.sizeBytes > 0
|
||||
? Math.round((item.downloadedBytes / item.sizeBytes) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className={`${s.item} ${s[`status_${item.status}`]}`}>
|
||||
<span className={`material-symbols-outlined ${s.statusIcon} ${s[`icon_${item.status}`]}`}>
|
||||
{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>
|
||||
|
||||
<span className={s.source}>{item.source}</span>
|
||||
|
||||
{(item.status === 'downloading' || item.status === 'queued') && (
|
||||
<div className={s.progressTrack}>
|
||||
<div className={s.progressBar} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'failed' && item.error && (
|
||||
<p className={s.error}>{item.error}</p>
|
||||
)}
|
||||
|
||||
<div className={s.actions}>
|
||||
{item.status === 'failed' && (
|
||||
<button className={s.textBtn} onClick={() => onRetry(item.id)}>Retry</button>
|
||||
)}
|
||||
<button className={s.textBtn} onClick={() => onRemove(item.id)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import QueueItem from '../QueueItem'
|
||||
import type { QueueItem as IQueueItem } from '../../../types'
|
||||
|
||||
function makeItem(overrides: Partial<IQueueItem> = {}): IQueueItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
filename: 'book.epub',
|
||||
sizeBytes: 1024 * 1024,
|
||||
downloadedBytes: 0,
|
||||
status: 'queued',
|
||||
source: 'https://example.com',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('QueueItem', () => {
|
||||
it('renders the filename', () => {
|
||||
render(<QueueItem item={makeItem()} onRetry={vi.fn()} onRemove={vi.fn()} />)
|
||||
expect(screen.getByText('book.epub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the source', () => {
|
||||
render(<QueueItem item={makeItem()} onRetry={vi.fn()} onRemove={vi.fn()} />)
|
||||
expect(screen.getByText('https://example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRemove with item id when Remove is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRemove = vi.fn()
|
||||
render(<QueueItem item={makeItem()} onRetry={vi.fn()} onRemove={onRemove} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(onRemove).toHaveBeenCalledWith('item-1')
|
||||
})
|
||||
|
||||
it('shows Retry button for failed items', () => {
|
||||
render(<QueueItem item={makeItem({ status: 'failed' })} onRetry={vi.fn()} onRemove={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRetry with item id when Retry is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRetry = vi.fn()
|
||||
render(<QueueItem item={makeItem({ status: 'failed' })} onRetry={onRetry} onRemove={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(onRetry).toHaveBeenCalledWith('item-1')
|
||||
})
|
||||
|
||||
it('does not show Retry button for non-failed items', () => {
|
||||
render(<QueueItem item={makeItem({ status: 'completed' })} onRetry={vi.fn()} onRemove={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders error message when status is failed and error is set', () => {
|
||||
render(<QueueItem item={makeItem({ status: 'failed', error: 'Connection refused' })} onRetry={vi.fn()} onRemove={vi.fn()} />)
|
||||
expect(screen.getByText('Connection refused')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatBytes } from '../utils'
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('returns bytes for values under 1 KB', () => {
|
||||
expect(formatBytes(0)).toBe('0 B')
|
||||
expect(formatBytes(512)).toBe('512 B')
|
||||
expect(formatBytes(1023)).toBe('1023 B')
|
||||
})
|
||||
|
||||
it('returns KB for values between 1 KB and 1 MB', () => {
|
||||
expect(formatBytes(1024)).toBe('1 KB')
|
||||
expect(formatBytes(2048)).toBe('2 KB')
|
||||
expect(formatBytes(1024 * 1024 - 1)).toBe('1024 KB')
|
||||
})
|
||||
|
||||
it('returns MB with one decimal for values over 1 MB', () => {
|
||||
expect(formatBytes(1024 * 1024)).toBe('1.0 MB')
|
||||
expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB')
|
||||
expect(formatBytes(10 * 1024 * 1024)).toBe('10.0 MB')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
.rail {
|
||||
width: var(--nav-rail-w);
|
||||
min-width: var(--nav-rail-w);
|
||||
height: 100%;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 4px 0 8px;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 200ms cubic-bezier(.2,0,0,1);
|
||||
}
|
||||
|
||||
/* Hover state layer */
|
||||
.link:hover .indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--md-sys-color-on-surface);
|
||||
opacity: .08;
|
||||
}
|
||||
|
||||
.link.active .indicator {
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
}
|
||||
|
||||
.link.active:hover .indicator::before {
|
||||
background: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 24px !important;
|
||||
transition: font-variation-settings 200ms;
|
||||
}
|
||||
|
||||
.iconFilled {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important;
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 6px;
|
||||
background: var(--md-sys-color-error);
|
||||
color: var(--md-sys-color-on-error);
|
||||
font: var(--md-sys-typescale-label-small);
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font: var(--md-sys-typescale-label-medium);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link.active .label {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.footerBtn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.footerBtn .indicator {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footerBtn:hover .indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--md-sys-color-on-surface);
|
||||
opacity: .08;
|
||||
}
|
||||
|
||||
.footerBtn .icon {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.footerBtn .label {
|
||||
font: var(--md-sys-typescale-label-medium);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import s from './Sidebar.module.css'
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<nav className={s.rail}>
|
||||
<div className={s.brand}>
|
||||
<span className={`material-symbols-outlined ${s.brandIcon}`}>auto_stories</span>
|
||||
</div>
|
||||
|
||||
<ul className={s.nav}>
|
||||
{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}
|
||||
</span>
|
||||
</div>
|
||||
<span className={s.label}>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className={s.footer}>
|
||||
<button className={s.footerBtn}>
|
||||
<div className={s.indicator}>
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
</div>
|
||||
<span className={s.label}>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
*, *::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);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { QueueItem as IQueueItem, ImportSource } from '../../types'
|
||||
import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../../api/importQueue'
|
||||
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',
|
||||
}
|
||||
|
||||
export default function Import() {
|
||||
const [queue, setQueue] = useState<IQueueItem[]>([])
|
||||
const [sources, setSources] = useState<ImportSource[]>([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue().then(setQueue)
|
||||
fetchSources().then(setSources)
|
||||
}, [])
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
console.log('dropped files:', Array.from(e.dataTransfer.files).map(f => f.name))
|
||||
}
|
||||
|
||||
function handleRetry(id: string) {
|
||||
retryQueueItem(id).then(() =>
|
||||
setQueue(q => q.map(i => i.id === id ? { ...i, status: 'queued' as const } : i))
|
||||
)
|
||||
}
|
||||
|
||||
function handleRemove(id: string) {
|
||||
removeQueueItem(id).then(() => setQueue(q => q.filter(i => i.id !== id)))
|
||||
}
|
||||
|
||||
function toggleSource(id: string) {
|
||||
const current = sources.find(s => s.id === id)
|
||||
if (!current) return
|
||||
const enabled = !current.enabled
|
||||
setSources(s => s.map(src => src.id === id ? { ...src, enabled } : src))
|
||||
updateSource(id, enabled).catch(() =>
|
||||
setSources(s => s.map(src => src.id === id ? { ...src, enabled: !enabled } : src))
|
||||
)
|
||||
}
|
||||
|
||||
const active = queue.filter(i => i.status === 'downloading' || i.status === 'queued')
|
||||
const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed')
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
{/* Left column */}
|
||||
<div className={s.col}>
|
||||
<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()}
|
||||
>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={s.heading}>Sources</h2>
|
||||
<ul className={s.sourceList}>
|
||||
{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>
|
||||
<div className={s.sourceInfo}>
|
||||
<span className={s.sourceName}>{src.name}</span>
|
||||
<span className={s.sourcePath}>{src.path}</span>
|
||||
</div>
|
||||
<button
|
||||
className={`${s.switch} ${src.enabled ? s.switchOn : ''}`}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`}
|
||||
>
|
||||
<span className={s.switchThumb} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className={s.addBtn}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
Add source
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className={s.col}>
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className={s.heading}>
|
||||
Downloading
|
||||
<span className={s.headingBadge}>{active.length}</span>
|
||||
</h2>
|
||||
<ul className={s.queueList}>
|
||||
{active.map(item => (
|
||||
<li key={item.id}>
|
||||
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{finished.length > 0 && (
|
||||
<section>
|
||||
<h2 className={s.heading}>History</h2>
|
||||
<ul className={s.queueList}>
|
||||
{finished.map(item => (
|
||||
<li key={item.id}>
|
||||
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{queue.length === 0 && (
|
||||
<div className={s.empty}>No recent activity.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* MD3 Search bar */
|
||||
.searchWrap {
|
||||
padding: 16px 16px 8px;
|
||||
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;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
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 AddBookDialog from '../../components/AddBookDialog/AddBookDialog'
|
||||
import s from './Library.module.css'
|
||||
|
||||
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)
|
||||
|
||||
const refreshBooks = () => fetchBooks().then(setBooks)
|
||||
useEffect(() => { refreshBooks() }, [])
|
||||
|
||||
const allGenres = useMemo(() =>
|
||||
[...new Set(books.flatMap(b => b.genres))].sort(), [books])
|
||||
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className={s.countBar}>
|
||||
<span className={s.count}>{filtered.length} of {books.length} books</span>
|
||||
{hasFilter && (
|
||||
<button className={s.clearBtn} onClick={() => { setGenres([]); setFormats([]); setQuery('') }}>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{addOpen && (
|
||||
<AddBookDialog
|
||||
onClose={() => setAddOpen(false)}
|
||||
onAdded={book => { setBooks(prev => [book, ...prev]); setAddOpen(false) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { filterBooks } from '../utils'
|
||||
import type { Book } from '../../../types'
|
||||
|
||||
function makeBook(overrides: Partial<Book> & { id: number; title: string }): Book {
|
||||
return {
|
||||
year: null,
|
||||
publisher: null,
|
||||
pages: null,
|
||||
description: null,
|
||||
formats: [],
|
||||
color: '#6366f1',
|
||||
genres: [],
|
||||
authors: [],
|
||||
coverUrl: null,
|
||||
isbn: null,
|
||||
hardcoverId: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const books: Book[] = [
|
||||
makeBook({ id: 1, title: 'Dune', authors: [{ id: 1, name: 'Frank Herbert' }], genres: ['Science Fiction'], formats: ['epub'] }),
|
||||
makeBook({ id: 2, title: 'Foundation', authors: [{ id: 2, name: 'Isaac Asimov' }], genres: ['Science Fiction', 'Classic'], formats: ['mobi', 'pdf'] }),
|
||||
makeBook({ id: 3, title: 'The Hobbit', authors: [{ id: 3, name: 'J.R.R. Tolkien' }], genres: ['Fantasy'], formats: ['epub', 'mobi'] }),
|
||||
]
|
||||
|
||||
describe('filterBooks', () => {
|
||||
it('returns all books when no filters applied', () => {
|
||||
expect(filterBooks(books, '', [], [])).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filters by title case-insensitively', () => {
|
||||
const result = filterBooks(books, 'dune', [], [])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Dune')
|
||||
})
|
||||
|
||||
it('filters by author name case-insensitively', () => {
|
||||
const result = filterBooks(books, 'tolkien', [], [])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('The Hobbit')
|
||||
})
|
||||
|
||||
it('filters by genre', () => {
|
||||
const result = filterBooks(books, '', ['Fantasy'], [])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('The Hobbit')
|
||||
})
|
||||
|
||||
it('filters by format', () => {
|
||||
const result = filterBooks(books, '', [], ['pdf'])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Foundation')
|
||||
})
|
||||
|
||||
it('applies genre and format filters with AND logic', () => {
|
||||
const result = filterBooks(books, '', ['Science Fiction'], ['mobi'])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Foundation')
|
||||
})
|
||||
|
||||
it('applies query and genre filter together', () => {
|
||||
const result = filterBooks(books, 'foundation', ['Science Fiction'], [])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Foundation')
|
||||
})
|
||||
|
||||
it('returns empty array when nothing matches', () => {
|
||||
expect(filterBooks(books, 'nonexistent', [], [])).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Book, Format } from '../../types'
|
||||
|
||||
export function filterBooks(
|
||||
books: Book[],
|
||||
query: string,
|
||||
genres: string[],
|
||||
formats: Format[],
|
||||
): Book[] {
|
||||
return books.filter(b => {
|
||||
if (
|
||||
query &&
|
||||
!b.title.toLowerCase().includes(query.toLowerCase()) &&
|
||||
!b.authors.some(a => a.name.toLowerCase().includes(query.toLowerCase()))
|
||||
) return false
|
||||
if (genres.length && !genres.some(g => b.genres.includes(g))) return false
|
||||
if (formats.length && !formats.some(f => b.formats.includes(f))) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
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);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
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;
|
||||
}
|
||||
|
||||
/* MD3 List Item */
|
||||
.bookItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--md-sys-shape-full);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 200ms;
|
||||
}
|
||||
|
||||
.bookItem::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--md-sys-color-on-surface);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.bookItem:hover::before { opacity: .08; }
|
||||
.bookItem:active::before { opacity: .12; }
|
||||
|
||||
.bookItemActive {
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
}
|
||||
|
||||
.bookItemActive::before {
|
||||
background: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 32px;
|
||||
height: 44px;
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookMeta {
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editorCover {
|
||||
width: 44px;
|
||||
height: 60px;
|
||||
border-radius: var(--md-sys-shape-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,.35);
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { Book } from '../../types'
|
||||
import { fetchBooks, updateBook } 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 [selected, setSelected] = useState<Book | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchBooks().then(list => {
|
||||
setBooks(list)
|
||||
if (list.length > 0) setSelected(list[0])
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
books.filter(b =>
|
||||
!query ||
|
||||
b.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
b.authors.some(a => a.name.toLowerCase().includes(query.toLowerCase()))
|
||||
), [books, query])
|
||||
|
||||
function handleSave(patch: Partial<Book>) {
|
||||
if (!selected) return
|
||||
updateBook(selected.id, patch).then(updated => {
|
||||
setBooks(bs => bs.map(b => b.id === updated.id ? updated : b))
|
||||
setSelected(updated)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.layout}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ul className={s.bookList}>
|
||||
{filtered.map(book => (
|
||||
<li
|
||||
key={book.id}
|
||||
className={`${s.bookItem} ${selected?.id === book.id ? s.bookItemActive : ''}`}
|
||||
onClick={() => setSelected(book)}
|
||||
>
|
||||
<div className={s.thumb} style={{ background: book.color }}>
|
||||
{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>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className={s.editor}>
|
||||
{selected ? (
|
||||
<>
|
||||
<div className={s.editorHeader}>
|
||||
<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>
|
||||
</div>
|
||||
<MetadataForm key={selected.id} book={selected} onSave={handleSave} />
|
||||
</>
|
||||
) : (
|
||||
<div className={s.empty}>Select a book to edit metadata</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id: string
|
||||
name: string
|
||||
type: SeriesType
|
||||
}
|
||||
|
||||
export interface SeriesEntry {
|
||||
seriesId: string
|
||||
bookId: string
|
||||
position: number
|
||||
arc?: string
|
||||
}
|
||||
|
||||
export interface BookAuthor {
|
||||
bookId: string
|
||||
authorId: string
|
||||
role: AuthorRole
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: number
|
||||
title: string
|
||||
year: number | null
|
||||
publisher: string | null
|
||||
pages: number | null
|
||||
description: string | null
|
||||
formats: Format[]
|
||||
color: string
|
||||
genres: string[]
|
||||
authors: Author[]
|
||||
series?: { name: string; position: number; arc?: string }
|
||||
// Metadata source fields
|
||||
coverUrl: string | null
|
||||
isbn: string | null
|
||||
hardcoverId: number | null
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
downloadedBytes: number
|
||||
status: QueueStatus
|
||||
source: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface HardcoverSearchResult {
|
||||
id: number
|
||||
title: string
|
||||
authors: string[]
|
||||
year: number | null
|
||||
genres: string[]
|
||||
}
|
||||
|
||||
export interface ImportSource {
|
||||
id: string
|
||||
name: string
|
||||
type: SourceType
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user