Compare commits
39
Commits
510306f24d
...
968166c1fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968166c1fd | ||
|
|
8589adbd1b | ||
|
|
5305d3bb5e | ||
|
|
7e04826fa5 | ||
|
|
96789620bb | ||
|
|
d4bdb5ed42 | ||
|
|
5f2d68694d | ||
|
|
d6207b5743 | ||
|
|
51c31e6bf6 | ||
|
|
961a63480e | ||
|
|
92ffa4f7c2 | ||
|
|
9155129ccd | ||
|
|
6318115380 | ||
|
|
fe12f4be76 | ||
|
|
190e7af76d | ||
|
|
783f4d226d | ||
|
|
c946b6d71c | ||
|
|
dd65e34869 | ||
|
|
a1281f129c | ||
|
|
ee46f5568c | ||
|
|
ac5a5c75aa | ||
|
|
49f94f9ee1 | ||
|
|
2aba90f910 | ||
|
|
d2892353cb | ||
|
|
d8f8a0ab95 | ||
|
|
4ee127b6cb | ||
|
|
6366582498 | ||
|
|
ef8e5e7fba | ||
|
|
c09b8365fa | ||
|
|
0139f6f5eb | ||
|
|
4c3fd66a56 | ||
|
|
f2b8f337f7 | ||
|
|
75360ff603 | ||
|
|
87fff20d72 | ||
|
|
adddcfeeed | ||
|
|
cb070336c6 | ||
|
|
f68a233dca | ||
|
|
f36aa14463 | ||
|
|
d71b53b3c5 |
+4
-1
@@ -1,2 +1,5 @@
|
||||
# Mark pdfjs as vendored code
|
||||
frontend/static/pdfjs/** linguist-vendored
|
||||
frontend/static/pdfjs/** linguist-vendored
|
||||
|
||||
# Mark foliate-js as vendored code
|
||||
frontend/src/lib/vendor/** linguist-vendored
|
||||
@@ -9,13 +9,15 @@ a KOSync-compatible endpoint.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
| --- | --- |
|
||||
| `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. |
|
||||
| `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. |
|
||||
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. |
|
||||
| `docs/screenshots/` | Images used by `README.md`. |
|
||||
| `shell.nix` | Root dev shell; composes the two sub-shells. |
|
||||
| Path | What |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. |
|
||||
| `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. |
|
||||
| `frontend/src/lib/vendor/` | Vendored `foliate-js` (the EPUB engine), copied by `frontend/scripts/vendor-foliate.sh`. |
|
||||
| `frontend/static/pdfjs/` | Vendored pdf.js viewer, used by the PDF reader in an iframe. |
|
||||
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. |
|
||||
| `docs/screenshots/` | Images used by `README.md`. |
|
||||
| `shell.nix` | Root dev shell; composes the two sub-shells. |
|
||||
|
||||
## Development environment
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# TODO
|
||||
|
||||
Known issues and deferred work. Agent-facing notes belong in the `AGENTS.md` files;
|
||||
this is for things that are broken or missing and not yet scheduled.
|
||||
|
||||
## Backend
|
||||
|
||||
### Identifier extraction drops most ISBNs
|
||||
|
||||
`backend/src/chitai/services/metadata_extractor.py` — `EpubExtractor._extract_identifiers`
|
||||
|
||||
The EPUB path validates `DC:identifier` values verbatim:
|
||||
|
||||
```python
|
||||
for id in epub.get_metadata("DC", "identifier"):
|
||||
if is_valid_isbn(id[0]):
|
||||
...
|
||||
```
|
||||
|
||||
`is_valid_isbn` branches on `len(isbn)` being exactly 10 or 13, so anything carrying
|
||||
formatting fails. Two consequences:
|
||||
|
||||
- **Hyphenated ISBNs are silently dropped.** `978-0-486-28211-4` is 17 characters, so it
|
||||
never reaches the checksum. Most EPUBs write ISBNs hyphenated, so the majority are lost.
|
||||
The PDF path already does this correctly — `_extract_isbns` calls
|
||||
`match.replace("-", "")` before validating.
|
||||
- **`urn:isbn:` prefixes are dropped** for the same reason. This is a common EPUB form.
|
||||
|
||||
Fix: normalise before validating — strip a leading `urn:isbn:`, then remove everything
|
||||
that isn't `0-9` or `X`. Reuse the PDF path's approach rather than duplicating it.
|
||||
|
||||
Note this only runs at upload, so fixing it changes nothing for books already imported.
|
||||
A backfill would need to re-read the files on disk.
|
||||
|
||||
### Non-ISBN identifiers are discarded
|
||||
|
||||
Same function. Anything that isn't a valid ISBN is thrown away, including values EPUBs
|
||||
routinely carry: `urn:uuid:…`, `calibre:…`, Google Books volume IDs and ASINs.
|
||||
|
||||
The `Identifier` model is already generic (`name` + `value`, unique per book), so storing
|
||||
them needs no schema change — only the extractor decides what survives. The frontend
|
||||
already renders ISBN, ASIN and DOI as links and shows unknown types as plain values, so
|
||||
anything stored will display sensibly.
|
||||
|
||||
Worth adding at the same time:
|
||||
|
||||
- A DOI regex (`10.\d{4,9}/\S+`) alongside the ISBN scan in `PdfExtractor._extract_isbns`
|
||||
— academic PDFs carry one and it is the most useful identifier they have.
|
||||
- Deriving ISBN-10 from ISBN-13 when only the latter is present. It is a pure checksum
|
||||
conversion and doubles the chance of an external lookup matching.
|
||||
|
||||
### Any authenticated user can delete any library or book
|
||||
|
||||
`backend/src/chitai/database/models/library.py`, `backend/src/chitai/database/models/user.py`
|
||||
|
||||
There is no authorization tier. `Library` has no owner column, and `User` carries only
|
||||
`email` and `password` — no role, no `is_active`. So every authenticated account can create
|
||||
and delete libraries, and delete books along with their files on disk. Per-user scoping
|
||||
exists only for reading progress and bookshelves, which `provide_book_service` restricts
|
||||
correctly.
|
||||
|
||||
For a single-household deployment that may well be acceptable. The point is that it is
|
||||
emergent rather than chosen. The cheapest meaningful step is an `is_admin` flag gating
|
||||
library deletion and `delete_books` — a model change plus a migration.
|
||||
|
||||
### Basic auth answers a malformed header with a 500
|
||||
|
||||
`backend/src/chitai/middleware/basic_auth.py` — line 22
|
||||
|
||||
```python
|
||||
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":")
|
||||
```
|
||||
|
||||
Nothing guards the parse. A `Bearer` token raises `IndexError`, non-base64 raises
|
||||
`binascii.Error`, and a credential with no colon raises `ValueError` — as does a password
|
||||
that *contains* one, since there is no `maxsplit=1`. Every case surfaces as a 500 on an
|
||||
unauthenticated endpoint. All of them should be 401.
|
||||
|
||||
### An unknown KOSync API key returns 404
|
||||
|
||||
`backend/src/chitai/middleware/kosync_auth.py` — line 32
|
||||
|
||||
`KosyncDeviceService.get_by_api_key` uses `get_one`, which raises `NotFoundError`, but the
|
||||
middleware catches only `PermissionDeniedException`. The global handler in
|
||||
`exceptions/handlers.py` then renders it as a 404, so a device presenting a bad key is told
|
||||
the route does not exist rather than that it is unauthorized. The same file still carries a
|
||||
leftover `print(exc)`.
|
||||
|
||||
Worth doing at the same time: `KosyncDeviceService._generate_api_key` uses
|
||||
`secrets.token_hex(8)`. 64 bits is thin for a long-lived bearer credential where 32 bytes
|
||||
is the convention.
|
||||
|
||||
### The multi-book download cannot be driven from a test
|
||||
|
||||
`backend/src/chitai/services/book.py` — `BookService.get_files`
|
||||
|
||||
`/books/download` is the only handler returning a Litestar `Stream`, and it cannot be
|
||||
exercised through `AsyncTestClient`. The request itself succeeds, then fixture teardown
|
||||
hangs: the test transport never sends the `http.disconnect` that the streaming response
|
||||
waits on, so the app's lifespan shutdown never completes. Coverage therefore sits at the
|
||||
service level, on `get_files` directly.
|
||||
|
||||
Unresolved whether the endpoint also stalls behind a real ASGI server, where that
|
||||
disconnect does arrive. Worth one manual check against `litestar run` before relying on it.
|
||||
|
||||
### The production image runs the development server
|
||||
|
||||
`backend/Dockerfile` — the final `CMD`
|
||||
|
||||
```
|
||||
CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian
|
||||
directly, with a worker count.
|
||||
|
||||
### Nothing gates formatting, linting or types
|
||||
|
||||
`ruff format --check src/` reports 27 of 61 files unformatted, and `ruff check src/` finds
|
||||
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and
|
||||
`== True` comparisons. `ruff check --fix` clears 51 automatically.
|
||||
|
||||
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F`
|
||||
rules run, and no type checker is configured at all despite `# type: ignore` comments in
|
||||
the tree. Individually these are trivial; collectively they say nothing runs on commit.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Scripted EPUBs run against the app origin
|
||||
|
||||
**This is a regression from the foliate-js migration, not a pre-existing gap.**
|
||||
|
||||
The old epub.js reader never passed `allowScriptedContent`. epub.js defaults it to
|
||||
`false`, which sets `iframe.sandbox = "allow-same-origin"` — no `allow-scripts` — so
|
||||
script inside a book never ran. The vendored foliate-js sets, unconditionally:
|
||||
|
||||
```js
|
||||
// paginator.js — and the same in fixed-layout.js
|
||||
// `allow-scripts` is needed for events because of WebKit bug
|
||||
this.#iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
|
||||
```
|
||||
|
||||
`allow-same-origin` together with `allow-scripts` is the combination that makes the
|
||||
sandbox attribute do nothing. Sections are served as same-origin `blob:` URLs, so script
|
||||
in a book can reach `/api/*` with the session cookie attached. foliate's README says as
|
||||
much and tells you to use a CSP instead; we have not added one.
|
||||
|
||||
This is not theoretical. Audiobookshelf shipped the same combination and got
|
||||
**CVE-2024-35236** — scripted EPUB plus an unrestricted upload gave remote code
|
||||
execution; fixed in 2.10.0 by making scripted content a per-library opt-in, off by
|
||||
default. Kavita (CVE-2024-39307) and Jellyfin (fixed 10.9.8) are variations on it.
|
||||
Write-up: <https://gebir.ge/blog/every-trick-in-the-book/>.
|
||||
|
||||
**An app-wide CSP is the wrong shape.** `kit.csp` with `script-src: ['self']` also blocks
|
||||
`mode-watcher`'s inline `setInitialMode`, which sets the dark class before first paint —
|
||||
SvelteKit only nonces the bootstrap script it injects itself, so every page load would
|
||||
flash the light theme. Pinning a hash of a third-party inline script breaks silently on
|
||||
upgrade.
|
||||
|
||||
**Grimmory solves it properly**, and it runs foliate-js too. Rather than handing foliate
|
||||
the whole file, it serves each EPUB entry from its own endpoint and puts the strict
|
||||
policy on that response:
|
||||
|
||||
```java
|
||||
// EpubReaderController.java
|
||||
response.setHeader("Content-Security-Policy", "script-src 'none'");
|
||||
```
|
||||
|
||||
The app shell keeps its own, more permissive policy. That works because each section is
|
||||
then a real same-origin document with its own header, rather than a `blob:` — and a
|
||||
`blob:` inherits the CSP of the document that created it, which is exactly why a header
|
||||
on `/api/books/download/…` would achieve nothing today.
|
||||
|
||||
Two ways forward:
|
||||
|
||||
1. **Cheap.** Patch the vendored `sandbox` attribute to drop `allow-scripts`, restoring
|
||||
what epub.js gave us. Cost is the WebKit bug the upstream comment cites: events inside
|
||||
the iframe get swallowed, which would likely break touch/swipe paging and possibly the
|
||||
in-iframe keyboard handling in `foliate-view.svelte`. Needs testing before trusting.
|
||||
2. **Right.** Follow grimmory: serve individual EPUB entries from the backend with
|
||||
`script-src 'none'` on each response, and drive foliate through its loader hooks
|
||||
instead of a whole-file blob. This is **net-new capability on both sides**, not a
|
||||
rewiring of something that exists — see below.
|
||||
|
||||
Option 2 also fixes the memory cost below, which is why it is worth more than it looks.
|
||||
|
||||
#### What option 2 actually involves
|
||||
|
||||
Today the browser fetches the whole `.epub` from `download/{book_id}/{file_id}`, which
|
||||
returns a Litestar `File` and knows nothing about the archive's contents. foliate then
|
||||
opens the zip **in the browser** (`makeZipLoader` in `view.js`) and turns every chapter,
|
||||
image and stylesheet into a `blob:` URL via `Loader.createURL` in `epub.js`. A `blob:`
|
||||
carries no headers of its own — it inherits the CSP of the document that created it —
|
||||
which is why there is nowhere to attach a policy except the app shell.
|
||||
|
||||
foliate's parser never touches the zip directly. `EPUB` is constructed with a loader:
|
||||
|
||||
```js
|
||||
// view.js — makeZipLoader is one implementation; makeDirectoryLoader below is another
|
||||
return { entries, loadText, loadBlob, getSize };
|
||||
```
|
||||
|
||||
`name` is the **zip entry path**, because `makeZipLoader` keys its map on
|
||||
`entry.filename` — so `OEBPS/Text/chapter01.xhtml`, `OEBPS/Images/cover.jpg`. The parser
|
||||
resolves hrefs from the OPF manifest into those names and asks the loader for them,
|
||||
without caring where the bytes come from. A third implementation that fetches over HTTP
|
||||
is the same shape.
|
||||
|
||||
**Backend.** An endpoint taking a path inside the archive, e.g.
|
||||
`GET books/{book_id}/files/{file_id}/entry/{path:path}`, returning a `Stream` over
|
||||
`zipfile.ZipFile.open(name)` so an entry never lands in memory whole, with the content
|
||||
type from the manifest and `Content-Security-Policy: script-src 'none'` on the response.
|
||||
|
||||
Two things to get right:
|
||||
|
||||
- **`path` is caller-supplied.** Resolve it against the archive's `namelist()` and reject
|
||||
anything absent, rather than trusting the string — `../` traversal is the hazard.
|
||||
- **`getSize` is synchronous** in foliate's loader contract, and it feeds `SectionProgress`,
|
||||
which produces the reading percentage. So the endpoint needs a companion that returns
|
||||
entry names and sizes up front — one extra call at open — because sizes cannot be
|
||||
discovered per request.
|
||||
|
||||
Opening the zip per request costs a central-directory read each time. Probably fine for
|
||||
chapter-sized reads, worth measuring rather than assuming.
|
||||
|
||||
Note the backend already reads inside EPUBs — `metadata_extractor.py` uses ebooklib at
|
||||
ingest for title, authors, identifiers and the cover. What is missing is serving an
|
||||
arbitrary entry by path, not the ability to open the archive.
|
||||
|
||||
**Frontend.** `foliate-view.svelte` stops calling `view.open(file)` and builds an `EPUB`
|
||||
around a loader backed by that endpoint. The whole-file fetch in `epub-reader.svelte`
|
||||
goes away with it.
|
||||
|
||||
### The proxy buffers whole files and drops range headers
|
||||
|
||||
Two separate problems that both live in `frontend/src/routes/api/[...path]/+server.ts`.
|
||||
|
||||
**Buffering.** Litestar already streams: `ASGIFileResponse` reads in 1 MB chunks
|
||||
(`response/file.py`), so the backend never holds a file whole. The proxy then undoes it
|
||||
with `await response.arrayBuffer()`, which does not resolve until the last byte arrives —
|
||||
so the whole file sits in the node process, per concurrent reader, and the browser gets
|
||||
nothing until it completes. Passing `response.body` straight through restores the stream
|
||||
and is a small change.
|
||||
|
||||
**Range.** The proxy forwards only `Content-Type`, `Content-Disposition` and
|
||||
`Content-Length`. It never sends the client's `Range` upstream, and would drop
|
||||
`Accept-Ranges` and `Content-Range` coming back — a 206 without `Content-Range` is
|
||||
broken. So range support cannot work until the proxy is fixed, whatever the backend does.
|
||||
|
||||
**Litestar has no range support of its own.** In 2.21.1 the only mention of 206 in the
|
||||
whole package is the `HTTP_206_PARTIAL_CONTENT` constant; there is no `Accept-Ranges` or
|
||||
`Content-Range` handling anywhere. This has to be written.
|
||||
|
||||
#### What pdf.js actually needs
|
||||
|
||||
It decides from the **initial 200 response**, not from anything on a 206.
|
||||
`validateRangeRequestCapabilities` in `frontend/static/pdfjs/build/pdf.mjs`:
|
||||
|
||||
```js
|
||||
if (responseHeaders.get("Accept-Ranges") !== "bytes") {
|
||||
return returnValues; // allowRangeRequests stays false
|
||||
}
|
||||
```
|
||||
|
||||
It also needs a parseable `Content-Length`, `Content-Encoding: identity`, and a length
|
||||
greater than twice `rangeChunkSize`. Miss any of those and it downloads the whole file
|
||||
however good the range support is.
|
||||
|
||||
So the single highest-value header is **`Accept-Ranges: bytes` on the ordinary 200** —
|
||||
that is what makes pdf.js switch to fetching progressively at all.
|
||||
|
||||
#### Approach
|
||||
|
||||
Put it on the existing `get_file` handler in `controllers/book.py`, which already resolves
|
||||
`book_id`/`file_id` through the service with library scoping and auth:
|
||||
|
||||
- No `Range` → `Stream` the file with `Accept-Ranges: bytes` and `Content-Length`.
|
||||
- `Range` present → parse, seek, `Stream` with 206 and `Content-Range`.
|
||||
- Proxy: forward `Range` up; pass `response.body` through; forward `Accept-Ranges`,
|
||||
`Content-Range` and the status back.
|
||||
|
||||
There are `RangeRequestMiddleware` snippets circulating for Litestar that wrap
|
||||
`create_static_files_router`. They are the wrong shape here — book files are served by an
|
||||
authenticated handler resolving database ids, not by a directory mapping, and using one
|
||||
would mean exposing disk paths as URLs and re-solving ownership checks that already
|
||||
exist. The common version also only sets `Accept-Ranges` on the 206, so it would not
|
||||
switch pdf.js over, and it derives its path with `str.lstrip(prefix)`, which strips a
|
||||
character set rather than a prefix — `/static/castle.pdf` becomes `le.pdf`. Worth reading
|
||||
its `parse_range_header` for the parsing rules and writing the rest fresh.
|
||||
|
||||
### Remove the epub.js locations-cache purge
|
||||
|
||||
`frontend/src/lib/reader/legacy-cache.ts` — `purgeLegacyLocationCache`
|
||||
|
||||
The epub.js reader cached generated locations in `localStorage` under
|
||||
`${bookId}-locations`, a few hundred KB of JSON per long book. foliate computes
|
||||
progress from section byte sizes at open time, so nothing writes those keys any
|
||||
more, but existing browsers still hold them — and a reader near the 5–10 MB
|
||||
origin quota would make the new reader-settings write throw `QuotaExceededError`.
|
||||
|
||||
The reader clears them once per browser, behind a `chitai:locations-purged` flag.
|
||||
Delete the module, its call in `epub-reader.svelte` and the flag once deployments
|
||||
have had a release or two to run it — after roughly 2026-12.
|
||||
|
||||
### Cover dimensions are unknown until load
|
||||
|
||||
`book-cover.svelte` renders covers at a fixed height with natural width so nothing is
|
||||
cropped or distorted. Because the intrinsic size is not known ahead of time, the text
|
||||
beside the cover settles once when the image loads. `min-width` bounds the movement but
|
||||
does not remove it.
|
||||
|
||||
Removing it properly means storing cover dimensions at ingest and emitting them as
|
||||
`width`/`height` attributes so the browser reserves exact space. That is a model change
|
||||
plus a migration.
|
||||
|
||||
### No series navigation
|
||||
|
||||
`Book` has `series` and `series_position`, and the detail page shows both, but there is no
|
||||
way to reach the other volumes. The books list endpoint filters by author, publisher, tag,
|
||||
shelf and progress — a `series` filter would need adding in
|
||||
`backend/src/chitai/services/filters/book.py` and wiring through
|
||||
`services/dependencies.py`, following the existing `AuthorFilter` pattern.
|
||||
|
||||
### Book pages are sparse for most books
|
||||
|
||||
EPUB metadata is thin: most imports arrive with a title, an author and nothing else. Two
|
||||
independent directions, neither started:
|
||||
|
||||
- **Use what exists.** "More by this author" (the list endpoint already accepts
|
||||
`authors=`), and exposing `Book.created_at` and `BookProgress.updated_at` — both are in
|
||||
the database, neither is on `BookRead` / `BookProgressRead`.
|
||||
- **Fetch from outside.** Open Library or Google Books lookup by ISBN for descriptions and
|
||||
covers. This is what actually fixes the sparseness, but it needs outbound requests, rate
|
||||
limiting, a manual-vs-automatic decision, and a rule for not clobbering hand-edited
|
||||
metadata.
|
||||
@@ -11,3 +11,6 @@ coverage
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
|
||||
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
|
||||
/src/lib/vendor/
|
||||
|
||||
+74
-16
@@ -4,13 +4,16 @@ SvelteKit web app for the eBook library. See the repo-root `AGENTS.md` for the o
|
||||
dev-environment setup.
|
||||
|
||||
**Stack:** SvelteKit 2 with `adapter-node` · Svelte 5 (runes) · Tailwind v4 · Zod v4 ·
|
||||
`epubjs` · `mode-watcher` (dark mode) · `svelte-sonner` (toasts) · pnpm.
|
||||
vendored `foliate-js` (EPUB) · vendored `pdf.js` (PDF) · `mode-watcher` (dark mode) ·
|
||||
`svelte-sonner` (toasts) · pnpm.
|
||||
|
||||
Two experimental flags are on in `svelte.config.js` and the codebase depends on both:
|
||||
`kit.experimental.remoteFunctions` and `compilerOptions.experimental.async` (`await` in components).
|
||||
|
||||
Tailwind v4 has **no config file** — the theme, oklch colour tokens and `@custom-variant dark` all
|
||||
live in `src/app.css`.
|
||||
Tailwind v4 has **no config file** — the theme, colour tokens (hex, not oklch) and
|
||||
`@custom-variant dark` all live in `src/app.css`. `dark` is the _only_ custom variant defined, so
|
||||
generated components that assume others — shadcn's slider ships `data-horizontal:` / `data-vertical:`
|
||||
classes — silently produce no styles. Use the `data-[orientation=…]` form instead.
|
||||
|
||||
## Talking to the backend
|
||||
|
||||
@@ -65,8 +68,12 @@ Svelte context with a module-level `Symbol` key and a `setXState` / `getXState`
|
||||
|
||||
```ts
|
||||
const LIBRARY_KEY = Symbol('LIBRARY');
|
||||
export function setLibraryState(libraries: Library[]) { return setContext(LIBRARY_KEY, new LibraryState(libraries)); }
|
||||
export function getLibraryState() { return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY); }
|
||||
export function setLibraryState(libraries: Library[]) {
|
||||
return setContext(LIBRARY_KEY, new LibraryState(libraries));
|
||||
}
|
||||
export function getLibraryState() {
|
||||
return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY);
|
||||
}
|
||||
```
|
||||
|
||||
Follow that pattern rather than introducing stores. `library.svelte.ts` is the reference — including
|
||||
@@ -79,10 +86,56 @@ its optimistic-delete-with-rollback and toast handling. `bookCollection` / `book
|
||||
`@ieedan/shadcn-svelte-extras` (`jsrepo.json`). Treat as generated: add components with the CLIs
|
||||
rather than hand-writing them, and prefer wrapping over editing.
|
||||
- App components live in `forms/`, `layout/`, `view/` (browser, grid/list/table, filters, sort) and
|
||||
`reader/` (epub reader + chapter sidebar).
|
||||
`reader/` (see [The readers](#the-readers)).
|
||||
- `cn()` from `$lib/utils` merges Tailwind classes; the `WithElementRef` / `WithoutChild` helpers
|
||||
there are the shadcn prop-typing conventions.
|
||||
|
||||
## The readers
|
||||
|
||||
**PDF** is the pdf.js viewer vendored under `static/pdfjs/`, pointed at by an iframe. Untouched by
|
||||
the EPUB work; leave it alone unless the task is about PDFs.
|
||||
|
||||
**EPUB** is built on `foliate-js`, copied verbatim into `src/lib/vendor/foliate-js/` by
|
||||
`scripts/vendor-foliate.sh` (pinned commit; see `src/lib/vendor/foliate-js/README.chitai.md`).
|
||||
Upstream has no npm release and recommends a submodule; this repo has none and already vendors
|
||||
pdf.js the same way, so it is copied instead. Only the import closure reachable from `view.js` is
|
||||
vendored, and **`pdf.js` in that directory is our stub, not upstream's** — the real one imports a
|
||||
bare `@pdfjs/pdf.min.mjs` that Rollup resolves at build time even though the path never runs.
|
||||
|
||||
Layout:
|
||||
|
||||
| Path | What |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `lib/vendor/foliate-js/` | The engine. Do not edit — `vendor-foliate.sh` overwrites it. |
|
||||
| `lib/reader/foliate.ts` | Lazy loader for the custom elements. The only thing that imports `$foliate`. |
|
||||
| `lib/reader/settings.ts` · `stylesheet.ts` | Defaults/bounds, and the CSS injected into the book. |
|
||||
| `lib/reader/progress.ts` | Debounced progress writer with a `sendBeacon` flush. |
|
||||
| `lib/state/reader-settings.svelte.ts` | Settings state, persisted to `localStorage`. |
|
||||
| `components/reader/foliate-view.svelte` | Wraps `<foliate-view>`; owns the imperative lifecycle. |
|
||||
| `components/reader/epub-reader.svelte` | The shell: chrome, TOC, errors, progress. |
|
||||
|
||||
Things that will bite:
|
||||
|
||||
- **`$foliate` is a Vite-only alias.** It is deliberately absent from `kit.alias` and tsconfig
|
||||
`paths` so TypeScript cannot resolve it and falls back to the ambient declaration in
|
||||
`lib/reader/foliate-js.d.ts`; `src/lib/vendor` is also in tsconfig `exclude`. Without both,
|
||||
`checkJs` walks ~11k lines of untyped JS. The declaration file must **not** be named `foliate.d.ts`
|
||||
— beside `foliate.ts`, TypeScript takes it for that file's emitted declaration and drops it.
|
||||
- **Never import the vendored code at module scope.** `view.js` calls `customElements.define` and
|
||||
subclasses `HTMLElement` on import, so it must stay behind `loadFoliate()` inside `onMount`. SSR is
|
||||
otherwise on for the reader route.
|
||||
- **Sections render in iframes, which swallow key events.** Keyboard handlers are bound per section
|
||||
document on the `load` event, and modifier combinations are replayed onto the host window so app
|
||||
shortcuts (the sidebar's ctrl+B) still work while reading.
|
||||
- **Renderer settings split two ways.** Flow, gap, margins, column count and line width are
|
||||
_attributes_ set with `setAttribute` (there is no JS property API, no `margin` shorthand and no
|
||||
`spread` — a spread is `max-column-count: 2`). Typography is CSS passed to `renderer.setStyles`,
|
||||
which takes a `[before, after]` pair: the first is prepended to the section head so the book
|
||||
overrides it, the second appended so it wins. User settings belong in the second, with
|
||||
`!important`, or the book's own CSS beats them.
|
||||
- **Progress needs no locations pre-pass.** `relocate` carries both a CFI and an overall `fraction`,
|
||||
which map straight onto `epub_cfi` and `percentage`.
|
||||
|
||||
## Routing
|
||||
|
||||
Route groups carry the layout structure:
|
||||
@@ -95,21 +148,26 @@ Route groups carry the layout structure:
|
||||
## Conventions
|
||||
|
||||
Prettier (`.prettierrc`): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and
|
||||
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done.
|
||||
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done —
|
||||
but take a baseline first, because neither is clean (see below).
|
||||
|
||||
`src/lib/vendor/` is excluded from Prettier, ESLint and svelte-check. Don't reformat vendored code.
|
||||
|
||||
## Known rough edges
|
||||
|
||||
Observed in the current tree — don't mistake these for intentional patterns to copy:
|
||||
|
||||
- `src/lib/schema/openapi/schema.d.ts` is **stale** — it predates the `BookProgress` rework and has
|
||||
no `BookProgressRead`, so `book.progress` types as `{}`. This is the source of most of the ~104
|
||||
errors `pnpm check` reports on a clean tree; regenerating it should clear them. Get a baseline
|
||||
before assuming an error is yours.
|
||||
no `BookProgressRead`, so `book.progress` types as `{}`. It is the source of most of the errors
|
||||
`pnpm check` reports; regenerating it should clear them. **Baseline as of 2026-08-11: 37 errors,
|
||||
1 warning, 11 files.** Get your own baseline before assuming an error is yours.
|
||||
- `pnpm lint` does not pass either — 131 pre-existing ESLint errors, 35 of them
|
||||
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
|
||||
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
|
||||
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
|
||||
import of that type is commented out at line 4.
|
||||
- `src/app.d.ts` — `App.Locals["user"]` is typed from `lucide-svelte`'s `User` *icon* component
|
||||
import of that type is commented out at line 4. It also buffers whole responses with
|
||||
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed.
|
||||
- `src/app.d.ts` — `App.Locals["user"]` is typed from `lucide-svelte`'s `User` _icon_ component
|
||||
rather than the `User` interface in `$lib/server/auth`.
|
||||
- Uncommitted work in progress (as of 2026-08-10): library icons, spanning
|
||||
`components/ui/icon-picker/`, the newly vendored `components/ui/popover/`,
|
||||
`forms/library-create-form.svelte`, `layout/library-switcher.svelte` and `schema/library.ts`.
|
||||
Prefer not to refactor those files mid-flight.
|
||||
- No CSP, which foliate's README asks for because EPUBs can carry scripts. See `TODO.md` for why it
|
||||
is not enabled yet.
|
||||
|
||||
@@ -12,6 +12,8 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
// Vendored third-party source. Tracked, so .gitignore does not cover it.
|
||||
{ ignores: ['src/lib/vendor/**'] },
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
@@ -27,6 +29,12 @@ export default defineConfig(
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
// Generated shadcn components take href as a prop and cannot resolve it —
|
||||
// that is the caller's job. Editing them here would be lost on regeneration.
|
||||
files: ['src/lib/components/ui/**'],
|
||||
rules: { 'svelte/no-navigation-without-resolve': 'off' }
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"jsrepo": "^2.5.2",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"prettier-plugin-svelte": "^3.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"epubjs": "^0.3.93",
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"svelte-sonner": "^1.0.8",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
Generated
+102
-291
File diff suppressed because it is too large
Load Diff
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Vendors foliate-js into src/lib/vendor/foliate-js/.
|
||||
#
|
||||
# foliate-js has no npm release and no build step; upstream recommends a git
|
||||
# submodule. We copy instead, because this repo has no submodules (pdf.js is
|
||||
# vendored the same way under static/pdfjs) and because a submodule would drag
|
||||
# in 231 files / 13 MB, of which 191 files / 12 MB is a bundled pdf.js build we
|
||||
# deliberately do not use — Chitai serves PDFs through static/pdfjs/web/viewer.html.
|
||||
#
|
||||
# Only the files reachable from view.js are copied: 15 upstream files, ~656 KB.
|
||||
# pdf.js is NOT copied; a stub is written in its place (see below).
|
||||
#
|
||||
# To update: bump FOLIATE_SHA, re-run, review the diff, then smoke-test the
|
||||
# reader — paginator.js is ~3800 lines of gesture and animation code and this
|
||||
# fork is pushed to frequently.
|
||||
#
|
||||
# Usage: ./scripts/vendor-foliate.sh
|
||||
set -euo pipefail
|
||||
|
||||
FOLIATE_REPO="https://github.com/readest/foliate-js.git"
|
||||
FOLIATE_SHA="63a2eb1fc1e4813c4e849ccdb3d4be2c54a35869"
|
||||
|
||||
DEST="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/src/lib/vendor/foliate-js"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# The reachable closure from view.js. Everything else upstream ships is either
|
||||
# unreachable (dict.js, opds.js, footnotes.js, quote-image.js, uri-template.js),
|
||||
# a demo (reader.js), or build tooling (rollup.config.js, eslint.config.js).
|
||||
FILES=(
|
||||
view.js
|
||||
epub.js
|
||||
epubcfi.js
|
||||
paginator.js
|
||||
fixed-layout.js
|
||||
overlayer.js
|
||||
progress.js
|
||||
search.js
|
||||
text-walker.js
|
||||
tts.js
|
||||
mobi.js
|
||||
comic-book.js
|
||||
fb2.js
|
||||
vendor/zip.js
|
||||
vendor/fflate.js
|
||||
)
|
||||
|
||||
echo "Cloning $FOLIATE_REPO @ ${FOLIATE_SHA:0:7} ..."
|
||||
git clone --quiet --filter=blob:none --no-checkout "$FOLIATE_REPO" "$TMP/foliate"
|
||||
git -C "$TMP/foliate" checkout --quiet "$FOLIATE_SHA"
|
||||
|
||||
rm -rf "$DEST"
|
||||
mkdir -p "$DEST/vendor"
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ ! -f "$TMP/foliate/$f" ]; then
|
||||
echo "ERROR: $f is missing upstream at ${FOLIATE_SHA:0:7}." >&2
|
||||
echo "The file list in this script is stale; re-check the import graph." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$TMP/foliate/$f" "$DEST/$f"
|
||||
done
|
||||
|
||||
cp "$TMP/foliate/LICENSE" "$DEST/LICENSE"
|
||||
|
||||
# view.js does `await import('./pdf.js')` inside makeBook. That is a static-string
|
||||
# dynamic import, so Rollup resolves it at build time whether or not the code path
|
||||
# ever runs — and upstream's pdf.js opens with `import '@pdfjs/pdf.min.mjs'`, a bare
|
||||
# specifier that does not resolve here. Shipping this stub at that path keeps the
|
||||
# build working without a Vite alias, and without vendoring 12 MB of pdf.js.
|
||||
cat > "$DEST/pdf.js" <<'STUB'
|
||||
// NOT upstream foliate-js. See README.chitai.md.
|
||||
//
|
||||
// Chitai renders PDFs with the pdf.js viewer vendored at static/pdfjs/, so
|
||||
// foliate's PDF backend is not vendored. view.js still references this module
|
||||
// from makeBook via a static-string dynamic import, which Rollup resolves at
|
||||
// build time regardless of whether it executes — so the file has to exist.
|
||||
//
|
||||
// Throwing at module scope surfaces a legible message in the reader's error
|
||||
// card if a PDF is ever routed to the EPUB reader by mistake, rather than a
|
||||
// TypeError from `globalThis.pdfjsLib` being undefined.
|
||||
throw new Error('foliate-js PDF rendering is not enabled in Chitai');
|
||||
STUB
|
||||
|
||||
cat > "$DEST/README.chitai.md" <<EOF
|
||||
# Vendored foliate-js
|
||||
|
||||
Do not edit these files. They are copied verbatim from upstream by
|
||||
\`frontend/scripts/vendor-foliate.sh\`; local changes are lost on the next run.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Upstream | <https://github.com/readest/foliate-js> (Readest's fork of johnfactotum/foliate-js) |
|
||||
| Pinned commit | \`$FOLIATE_SHA\` |
|
||||
| Licence | MIT — see \`LICENSE\` |
|
||||
|
||||
Readest's fork is used rather than upstream for its paginator work: touch/swipe
|
||||
turn handling, fixed-layout spread centring, and a malformed-XHTML fallback in
|
||||
\`loadDocument\`.
|
||||
|
||||
## What is here
|
||||
|
||||
Only the import closure reachable from \`view.js\`. Not vendored, because nothing
|
||||
reaches them: \`dict.js\`, \`opds.js\`, \`footnotes.js\`, \`quote-image.js\`,
|
||||
\`uri-template.js\`, \`reader.js\` (upstream's demo), and the build configs.
|
||||
|
||||
## pdf.js is ours, not upstream's
|
||||
|
||||
\`pdf.js\` in this directory is a **stub that throws**. Upstream's version imports
|
||||
\`@pdfjs/pdf.min.mjs\` — a bare specifier backed by a 12 MB vendored pdf.js build —
|
||||
and \`view.js\` reaches it through \`await import('./pdf.js')\`, which Rollup resolves
|
||||
at build time even though Chitai never takes that path. Chitai serves PDFs from
|
||||
\`static/pdfjs/web/viewer.html\` instead.
|
||||
|
||||
To enable foliate's PDF backend, add \`pdf.js\` and \`vendor/pdfjs/\` to the file list
|
||||
in the vendor script and drop the stub.
|
||||
|
||||
## Updating
|
||||
|
||||
Bump \`FOLIATE_SHA\` in \`frontend/scripts/vendor-foliate.sh\`, re-run it, review the
|
||||
diff, and smoke-test the reader — \`paginator.js\` is ~3800 lines of gesture and
|
||||
animation code and this fork is pushed to frequently.
|
||||
EOF
|
||||
|
||||
echo
|
||||
echo "Vendored ${#FILES[@]} files + LICENSE + pdf.js stub + README.chitai.md to:"
|
||||
echo " $DEST"
|
||||
du -sh "$DEST" | sed 's/^/ /'
|
||||
+92
-62
@@ -8,74 +8,100 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
|
||||
/* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
|
||||
--app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
|
||||
--app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Reading Room — light */
|
||||
--background: #e9ebee;
|
||||
--foreground: #1b1f24;
|
||||
--card: #fbfbfc;
|
||||
--card-foreground: #1b1f24;
|
||||
--popover: #fbfbfc;
|
||||
--popover-foreground: #1b1f24;
|
||||
--primary: #1f5f5b;
|
||||
--primary-foreground: #f2f7f6;
|
||||
--secondary: #dfe3e8;
|
||||
--secondary-foreground: #1b1f24;
|
||||
--muted: #e2e5e9;
|
||||
--muted-foreground: #7d858f;
|
||||
--accent: #d7e5e3;
|
||||
--accent-foreground: #164743;
|
||||
--destructive: #a6402f;
|
||||
--border: #d2d6dc;
|
||||
--input: #d2d6dc;
|
||||
--ring: #1f5f5b;
|
||||
|
||||
/* Semantic — deliberately not the accent, so state never reads as branding. */
|
||||
--success: #2f7d4f;
|
||||
--success-foreground: #f2f7f6;
|
||||
--flag: #b08a1e;
|
||||
--star: #c79a25;
|
||||
|
||||
--chart-1: #1f5f5b;
|
||||
--chart-2: #2f7d4f;
|
||||
--chart-3: #b08a1e;
|
||||
--chart-4: #4c6b8a;
|
||||
--chart-5: #a6402f;
|
||||
|
||||
--sidebar: #e2e5e9;
|
||||
--sidebar-foreground: #1b1f24;
|
||||
--sidebar-primary: #1f5f5b;
|
||||
--sidebar-primary-foreground: #f2f7f6;
|
||||
--sidebar-accent: #d7e5e3;
|
||||
--sidebar-accent-foreground: #164743;
|
||||
--sidebar-border: #d2d6dc;
|
||||
--sidebar-ring: #1f5f5b;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
/* Reading Room — dark */
|
||||
--background: #15191b;
|
||||
--foreground: #e6eae9;
|
||||
--card: #1d2226;
|
||||
--card-foreground: #e6eae9;
|
||||
--popover: #1d2226;
|
||||
--popover-foreground: #e6eae9;
|
||||
--primary: #6fbab0;
|
||||
--primary-foreground: #0e1a19;
|
||||
--secondary: #232a2d;
|
||||
--secondary-foreground: #e6eae9;
|
||||
--muted: #232a2d;
|
||||
--muted-foreground: #78868a;
|
||||
--accent: #1b3330;
|
||||
--accent-foreground: #9fd8d0;
|
||||
--destructive: #e0796b;
|
||||
--border: #2a3034;
|
||||
--input: #2a3034;
|
||||
--ring: #6fbab0;
|
||||
|
||||
--success: #4fa97a;
|
||||
--success-foreground: #0e1a19;
|
||||
--flag: #d4a63a;
|
||||
--star: #e5b84b;
|
||||
|
||||
--chart-1: #6fbab0;
|
||||
--chart-2: #4fa97a;
|
||||
--chart-3: #d4a63a;
|
||||
--chart-4: #7f9dc0;
|
||||
--chart-5: #e0796b;
|
||||
|
||||
--sidebar: #111517;
|
||||
--sidebar-foreground: #e6eae9;
|
||||
--sidebar-primary: #6fbab0;
|
||||
--sidebar-primary-foreground: #0e1a19;
|
||||
--sidebar-accent: #1b3330;
|
||||
--sidebar-accent-foreground: #9fd8d0;
|
||||
--sidebar-border: #2a3034;
|
||||
--sidebar-ring: #6fbab0;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--app-font-sans);
|
||||
--font-serif: var(--app-font-serif);
|
||||
--font-mono: var(--app-font-mono);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -95,6 +121,10 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-flag: var(--flag);
|
||||
--color-star: var(--star);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
Vendored
+3
-1
@@ -2,7 +2,8 @@
|
||||
// for information about these interfaces
|
||||
|
||||
import type { ApiClient } from '$lib/server/api';
|
||||
import type { User } from 'lucide-svelte';
|
||||
import type { User } from '$lib/server/auth';
|
||||
import type { ThemeConfig } from '$lib/theme/presets';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
@@ -11,6 +12,7 @@ declare global {
|
||||
authToken: string | null;
|
||||
api: ApiClient;
|
||||
user: User;
|
||||
theme: ThemeConfig;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<!--theme-->
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="overflow-hidden">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiClient } from '$lib/server/api';
|
||||
import { validateToken } from '$lib/server/auth';
|
||||
import { THEME_COOKIE, parseThemeCookie, themeToCss } from '$lib/theme/presets';
|
||||
import { redirect, type Handle } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
|
||||
@@ -34,4 +35,28 @@ const protectedRoutesHandle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle = sequence(authHandle, protectedRoutesHandle);
|
||||
/**
|
||||
* Inline the stored theme into the document head.
|
||||
*
|
||||
* The palette has to be in the very first byte of HTML the browser paints,
|
||||
* otherwise every page load flashes the default theme before hydration swaps
|
||||
* it. The `<!--theme-->` placeholder in app.html is the injection point.
|
||||
*/
|
||||
const themeHandle: Handle = async ({ event, resolve }) => {
|
||||
const config = parseThemeCookie(event.cookies.get(THEME_COOKIE));
|
||||
event.locals.theme = config;
|
||||
|
||||
return resolve(event, {
|
||||
// The placeholder comment is kept, not replaced. Svelte 5 uses HTML
|
||||
// comments as hydration markers, so SvelteKit warns when a chunk comes
|
||||
// back with fewer comments than it went in with — removing this one is
|
||||
// enough to trip that check.
|
||||
transformPageChunk: ({ html }) =>
|
||||
html.replace(
|
||||
'<!--theme-->',
|
||||
`<!--theme--><style id="chitai-theme">${themeToCss(config)}</style>`
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
export const handle = sequence(themeHandle, authHandle, protectedRoutesHandle);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { FileUp, Folder, FileText } from '@lucide/svelte';
|
||||
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import type { FileRejectedReason } from '$lib/components/ui/file-drop-zone';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let {
|
||||
onUpload,
|
||||
onFileRejected,
|
||||
accept,
|
||||
maxFileSize,
|
||||
disabled = false,
|
||||
class: className
|
||||
}: {
|
||||
onUpload: (files: File[]) => Promise<void> | void;
|
||||
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
|
||||
/** Comma separated extensions and/or MIME types, as the `accept` attribute takes. */
|
||||
accept?: string;
|
||||
/** Bytes. */
|
||||
maxFileSize?: number;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
/**
|
||||
* Two inputs rather than one.
|
||||
*
|
||||
* `webkitdirectory` is not a filter — it switches the picker into folder mode,
|
||||
* so a single input can offer files or folders but never both. The drop target
|
||||
* has no such constraint and stays one area.
|
||||
*/
|
||||
let fileInput = $state<HTMLInputElement>();
|
||||
let folderInput = $state<HTMLInputElement>();
|
||||
|
||||
let dragging = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
const active = $derived(!disabled && !busy);
|
||||
|
||||
function accepts(file: File): FileRejectedReason | undefined {
|
||||
if (maxFileSize !== undefined && file.size > maxFileSize) return 'Maximum file size exceeded';
|
||||
if (!accept) return undefined;
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
const type = file.type.toLowerCase();
|
||||
|
||||
const ok = accept
|
||||
.split(',')
|
||||
.map((pattern) => pattern.trim().toLowerCase())
|
||||
.some((pattern) => {
|
||||
// Match on the pattern, not the file's type. Testing `type` here is
|
||||
// what makes MOBI fail: browsers report no MIME type for it, so a
|
||||
// ".mobi" rule never gets compared against the filename.
|
||||
if (pattern.startsWith('.')) return name.endsWith(pattern);
|
||||
if (pattern.endsWith('/*')) return type.startsWith(pattern.slice(0, -1));
|
||||
return type === pattern;
|
||||
});
|
||||
|
||||
return ok ? undefined : 'File type not allowed';
|
||||
}
|
||||
|
||||
/** readEntries hands back at most 100 at a time and signals the end with an empty batch. */
|
||||
function readAll(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
|
||||
const next = () =>
|
||||
reader.readEntries((batch) => {
|
||||
if (batch.length === 0) return resolve(entries);
|
||||
entries.push(...batch);
|
||||
next();
|
||||
}, reject);
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a dropped entry into files, naming each one with its path inside the
|
||||
* dropped folder so it matches what the folder picker puts in
|
||||
* `webkitRelativePath` — which is what the upload form reads to keep structure.
|
||||
*/
|
||||
async function walk(entry: FileSystemEntry, prefix = ''): Promise<File[]> {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) =>
|
||||
(entry as FileSystemFileEntry).file(resolve, reject)
|
||||
);
|
||||
return [new File([file], `${prefix}${file.name}`, { type: file.type })];
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
const entries = await readAll((entry as FileSystemDirectoryEntry).createReader());
|
||||
const nested = await Promise.all(entries.map((e) => walk(e, `${prefix}${entry.name}/`)));
|
||||
return nested.flat();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
if (!active) return;
|
||||
|
||||
// Read the entries synchronously: DataTransfer is emptied as soon as this
|
||||
// handler yields, so awaiting first loses everything that was dropped.
|
||||
const entries = Array.from(event.dataTransfer?.items ?? [])
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.webkitGetAsEntry())
|
||||
.filter((entry): entry is FileSystemEntry => entry !== null);
|
||||
|
||||
// Older engines expose no entries; fall back to the flat list, which cannot
|
||||
// contain folders anyway.
|
||||
if (entries.length === 0) {
|
||||
await submit(Array.from(event.dataTransfer?.files ?? []));
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
const nested = await Promise.all(entries.map((entry) => walk(entry)));
|
||||
await submit(nested.flat());
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChange(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const chosen = Array.from(input.files ?? []);
|
||||
// Reset first so picking the same file twice still fires a change event.
|
||||
input.value = '';
|
||||
await submit(chosen);
|
||||
}
|
||||
|
||||
async function submit(candidates: File[]) {
|
||||
const accepted: File[] = [];
|
||||
|
||||
for (const file of candidates) {
|
||||
const reason = accepts(file);
|
||||
if (reason) onFileRejected?.({ file, reason });
|
||||
else accepted.push(file);
|
||||
}
|
||||
|
||||
if (accepted.length > 0) await onUpload(accepted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Add books"
|
||||
aria-disabled={!active}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
if (active) dragging = true;
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={handleDrop}
|
||||
class={cn(
|
||||
'flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-border bg-accent/20 p-6 text-center transition-colors',
|
||||
dragging && 'border-primary bg-accent/50',
|
||||
!active && 'opacity-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class="flex size-12 place-items-center justify-center rounded-full border border-dashed border-border text-muted-foreground"
|
||||
>
|
||||
<FileUp class="size-5" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium">
|
||||
{busy ? 'Reading folder…' : 'Drop books here'}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">A folder keeps its structure</span>
|
||||
</div>
|
||||
|
||||
<span class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">or</span>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!active}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<FileText class="size-4" />
|
||||
Choose files
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!active}
|
||||
onclick={() => folderInput?.click()}
|
||||
>
|
||||
<Folder class="size-4" />
|
||||
Choose folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
{accept}
|
||||
class="hidden"
|
||||
onchange={handleChange}
|
||||
/>
|
||||
<!-- webkitdirectory is why this needs to be a second input: it turns the
|
||||
picker into a folder chooser rather than filtering what it accepts. -->
|
||||
<input
|
||||
bind:this={folderInput}
|
||||
type="file"
|
||||
multiple
|
||||
webkitdirectory
|
||||
class="hidden"
|
||||
onchange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,165 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { X } from '@lucide/svelte';
|
||||
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import {
|
||||
displaySize,
|
||||
FileDropZone,
|
||||
type FileDropZoneProps
|
||||
} from '$lib/components/ui/file-drop-zone';
|
||||
import { X } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Switch } from '$lib/components/ui/switch/index';
|
||||
import { displaySize, type FileRejectedReason } from '$lib/components/ui/file-drop-zone';
|
||||
import BookDropZone from './book-drop-zone.svelte';
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { uploadBooks } from '$lib/api';
|
||||
import type { Book, PaginatedResponse } from '$lib/schema';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
||||
|
||||
let { open = $bindable() }: { open?: boolean } = $props();
|
||||
|
||||
let libraryState = getLibraryState();
|
||||
|
||||
$effect(() => {
|
||||
uploadBooks.fields.library_id.set(libraryState.activeLibrary!.id);
|
||||
});
|
||||
|
||||
let files = $derived(uploadBooks.fields.files.value() ?? []);
|
||||
const libraryState = getLibraryState();
|
||||
const queue = getUploadQueueState();
|
||||
|
||||
let libraryId = $state<number>(untrack(() => libraryState.activeLibrary!.id));
|
||||
let files = $state<File[]>([]);
|
||||
let autoUploadOnDrop = $state(true);
|
||||
let navigateOnUpload = $state(true);
|
||||
let formEl = $state<HTMLFormElement>();
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
// Rename files to use webkitRelativePath so directory structure is preserved through form submission
|
||||
const renamedFiles = uploadedFiles.map(f =>
|
||||
new File([f], f.webkitRelativePath || f.name, { type: f.type })
|
||||
const totalSize = $derived(files.reduce((sum, file) => sum + file.size, 0));
|
||||
|
||||
/**
|
||||
* Collected rather than raised one at a time. A folder of a few hundred books
|
||||
* carries covers and notes alongside them, and a toast per rejected file
|
||||
* buries the screen.
|
||||
*/
|
||||
let rejected = $state<{ name: string; reason: FileRejectedReason }[]>([]);
|
||||
let showRejected = $state(false);
|
||||
|
||||
// Start each visit clean — a list left over from last time reads as though it
|
||||
// applies to what was just chosen.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
untrack(() => {
|
||||
files = [];
|
||||
rejected = [];
|
||||
showRejected = false;
|
||||
libraryId = libraryState.activeLibrary!.id;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const onUpload = async (uploadedFiles: File[]) => {
|
||||
// Rename to the path relative to the chosen folder, which is what decides
|
||||
// how the API groups files into books. Dropped folders already arrive named
|
||||
// this way, since the drop zone builds the path while walking them.
|
||||
const named = uploadedFiles.map(
|
||||
(file) => new File([file], file.webkitRelativePath || file.name, { type: file.type })
|
||||
);
|
||||
uploadBooks.fields.files.set([...Array.from(files), ...renamedFiles]);
|
||||
if (autoUploadOnDrop && files.length > 0) {
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
|
||||
// Same relative path twice is the same file — dropping a folder a second
|
||||
// time should not queue everything again.
|
||||
const seen = new Set(files.map((file) => file.name));
|
||||
files = [...files, ...named.filter((file) => !seen.has(file.name))];
|
||||
|
||||
if (autoUploadOnDrop) await startUpload();
|
||||
};
|
||||
|
||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
||||
const onFileRejected = ({ reason, file }: { reason: FileRejectedReason; file: File }) => {
|
||||
rejected = [...rejected, { name: file.webkitRelativePath || file.name, reason }];
|
||||
};
|
||||
|
||||
function navigateToBooks(books: PaginatedResponse<Book>) {
|
||||
/**
|
||||
* Files picked from a folder carry their whole relative path as the name, so
|
||||
* truncating the end would cut off the filename — the only part worth reading.
|
||||
* Split it and let the folder sit on its own, quieter line.
|
||||
*/
|
||||
function splitPath(path: string) {
|
||||
const cut = path.lastIndexOf('/');
|
||||
return cut === -1
|
||||
? { dir: '', name: path }
|
||||
: { dir: path.slice(0, cut), name: path.slice(cut + 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the books to the queue and closes.
|
||||
*
|
||||
* Nothing is awaited here: the queue lives in the root layout and reports
|
||||
* through the tray, so the import carries on while the library stays usable.
|
||||
*/
|
||||
function startUpload() {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const queued = files;
|
||||
const target = libraryId;
|
||||
|
||||
files = [];
|
||||
rejected = [];
|
||||
open = false;
|
||||
let libraryId = books.items[0].library_id;
|
||||
libraryState.setActive(libraryId);
|
||||
if (books.items.length === 1) {
|
||||
goto(`/book/${books.items[0].id}`);
|
||||
} else {
|
||||
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`);
|
||||
}
|
||||
|
||||
queue.enqueue(target, queued, ({ created, firstBook }) => {
|
||||
if (created === 0) return;
|
||||
|
||||
const library = libraryState.libraries.find((lib) => lib.id === target);
|
||||
if (library) library.total = (library.total ?? 0) + created;
|
||||
|
||||
// Only for a single book. Jumping somewhere after a bulk import would
|
||||
// land minutes after the reader moved on.
|
||||
if (navigateOnUpload && created === 1 && firstBook) {
|
||||
libraryState.setActive(target);
|
||||
void goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(firstBook.id) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
{#if uploadBooks.pending}
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<span class="text-lg font-semibold"
|
||||
>Uploading {uploadBooks.fields.files.value().length} files...</span
|
||||
>
|
||||
<Spinner class="scale-150" />
|
||||
</div>
|
||||
{:else}
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Upload Books</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<!--
|
||||
Wider than the default lg: a folder's worth of rows needs the room.
|
||||
|
||||
<form
|
||||
{...uploadBooks.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
overflow-hidden and the min-w-0 on the body below are what keep a long
|
||||
filename inside the dialog. Dialog.Content is a grid, and grid and flex
|
||||
items default to min-width:auto — they refuse to shrink below their
|
||||
content's intrinsic width, so one long name widened the body and pushed it
|
||||
straight through the dialog's edge regardless of any truncate further down.
|
||||
-->
|
||||
<Dialog.Content class="overflow-hidden sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Add books</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Files or a folder. A folder becomes one book per directory.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = uploadBooks.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update library book count
|
||||
const count = uploadBooks.result.total
|
||||
libraryState.libraries.find(lib => uploadBooks.fields.library_id.value() == lib.id.toString())!.total += count
|
||||
|
||||
// Reset the files field
|
||||
uploadBooks.fields.files.set([]);
|
||||
toast.success('Books successfully uploaded!');
|
||||
|
||||
if (navigateOnUpload) {
|
||||
navigateToBooks(uploadBooks.result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload book: ', error);
|
||||
toast.error('Failed to upload books');
|
||||
}
|
||||
})}
|
||||
bind:this={formEl}
|
||||
enctype="multipart/form-data"
|
||||
class="flex w-full flex-col gap-2 p-4"
|
||||
>
|
||||
<!-- Library select field -->
|
||||
<Field.Label for="library_id">Select Library</Field.Label>
|
||||
<NativeSelect.Root {...uploadBooks.fields.library_id.as('select')} class="w-36">
|
||||
{#each libraryState.libraries as library}
|
||||
<NativeSelect.Option value={library.id}>
|
||||
{library.name}
|
||||
</NativeSelect.Option>
|
||||
<div class="flex w-full min-w-0 flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Field.Label for="library_id">Library</Field.Label>
|
||||
<NativeSelect.Root id="library_id" bind:value={libraryId} class="w-48">
|
||||
{#each libraryState.libraries as library (library.id)}
|
||||
<NativeSelect.Option value={library.id}>{library.name}</NativeSelect.Option>
|
||||
{/each}
|
||||
</NativeSelect.Root>
|
||||
</div>
|
||||
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
directory={true}
|
||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
||||
/>
|
||||
<input class="hidden" {...uploadBooks.fields.files.as('file multiple')} />
|
||||
<div class="flex max-h-[300px] flex-col gap-2 overflow-y-auto">
|
||||
{#each files as file, idx}
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
uploadBooks.fields.files.set([
|
||||
...Array.from(files).slice(0, idx),
|
||||
...Array.from(files).slice(idx + 1)
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
<BookDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||
/>
|
||||
|
||||
{#if files.length > 0}
|
||||
<div class="flex items-baseline justify-between border-b pb-1 text-sm">
|
||||
<span><strong class="tabular-nums">{files.length}</strong> ready to upload</span>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{displaySize(totalSize)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex max-h-[300px] min-w-0 flex-col gap-2 overflow-y-auto">
|
||||
{#each files as file, idx (file.name)}
|
||||
{@const location = splitPath(file.name)}
|
||||
<div class="flex min-w-0 items-center justify-between gap-2">
|
||||
<!-- flex-1 as well as min-w-0: without a constrained width there is
|
||||
nothing for truncate to act against and the row pushes the dialog wide -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-sm" title={file.name}>{location.name}</span>
|
||||
<span class="flex min-w-0 items-baseline gap-2 text-xs text-muted-foreground">
|
||||
{#if location.dir}
|
||||
<span class="truncate font-mono" title={location.dir}>{location.dir}</span>
|
||||
{/if}
|
||||
<span class="shrink-0 tabular-nums">{displaySize(file.size)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="shrink-0"
|
||||
onclick={() => (files = files.filter((_, i) => i !== idx))}
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Remove {file.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch bind:checked={autoUploadOnDrop} />
|
||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
||||
<Button type="submit" class="ml-auto w-fit">Upload</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch bind:checked={navigateOnUpload} />
|
||||
<Field.Label for="navigate-to-book">Navigate to book on upload</Field.Label>
|
||||
{#if rejected.length > 0}
|
||||
<div class="rounded-md border border-star/50 bg-star/10 p-2 text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{rejected.length}
|
||||
{rejected.length === 1 ? 'file was' : 'files were'} skipped
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onclick={() => (showRejected = !showRejected)}>
|
||||
{showRejected ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if showRejected}
|
||||
<ul class="mt-2 flex max-h-32 min-w-0 flex-col gap-1 overflow-y-auto">
|
||||
{#each rejected as entry (entry.name)}
|
||||
<li class="flex min-w-0 justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span class="min-w-0 flex-1 truncate" title={entry.name}>
|
||||
{splitPath(entry.name).name}
|
||||
</span>
|
||||
<span class="shrink-0">{entry.reason}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="auto-upload-on-drop" bind:checked={autoUploadOnDrop} />
|
||||
<Field.Label for="auto-upload-on-drop" class="font-normal">
|
||||
Start as soon as books are added
|
||||
</Field.Label>
|
||||
<Button class="ml-auto w-fit" disabled={files.length === 0} onclick={startUpload}>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="navigate-to-book" bind:checked={navigateOnUpload} />
|
||||
<Field.Label for="navigate-to-book" class="font-normal">
|
||||
Open the book when a single one is added
|
||||
</Field.Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,39 +1,68 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { type Book } from '$lib/schema';
|
||||
import EditCover from './edit-cover.svelte';
|
||||
import EditFiles from './edit-files.svelte';
|
||||
import EditMetadata from './edit-metadata.svelte';
|
||||
|
||||
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
|
||||
|
||||
/**
|
||||
* The metadata form lives in a child, but Save belongs in the dialog footer —
|
||||
* a submit button reaches it by id rather than the footer having to duplicate
|
||||
* the submit logic.
|
||||
*/
|
||||
const METADATA_FORM_ID = 'edit-book-metadata';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
|
||||
changes underneath it as different books are edited. bookToEdit is never
|
||||
cleared, so {#if book} stays true and the forms never unmount — they seed
|
||||
local state from `book` on mount, so without this key you would open Edit on a
|
||||
second book and see the first book's authors, tags and cover, then save them
|
||||
onto the wrong record. Keying on the id remounts the forms per book.
|
||||
-->
|
||||
<Dialog.Root bind:open>
|
||||
{#if book}
|
||||
<Dialog.Content class="sm:max-w-xl">
|
||||
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]">
|
||||
<Tabs.List class="grid w-full grid-cols-3">
|
||||
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
|
||||
<Tabs.Trigger value="cover">Cover</Tabs.Trigger>
|
||||
<Tabs.Trigger value="files">Files</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
{#key book.id}
|
||||
<Dialog.Content
|
||||
class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
|
||||
>
|
||||
<Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
|
||||
<Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
|
||||
>
|
||||
<Dialog.Description class="truncate text-xs">
|
||||
{book.authors.map((author) => author.name).join(', ') || 'Unknown author'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Metadata form -->
|
||||
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1">
|
||||
<EditMetadata {book} {open} />
|
||||
</Tabs.Content>
|
||||
<!-- Rail and form scroll independently so the footer never moves -->
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_1fr]">
|
||||
<aside
|
||||
class="flex min-w-0 flex-col gap-5 overflow-y-auto border-b bg-sidebar p-4 md:border-r md:border-b-0"
|
||||
>
|
||||
<EditCover {book} />
|
||||
<EditFiles {book} />
|
||||
</aside>
|
||||
|
||||
<!-- Cover form -->
|
||||
<Tabs.Content value="cover">
|
||||
<EditCover {book} {open} />
|
||||
</Tabs.Content>
|
||||
<div class="min-h-0 overflow-y-auto p-5">
|
||||
<EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add files form -->
|
||||
<Tabs.Content value="files">
|
||||
<EditFiles {book} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
<Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
|
||||
<!-- Cover and file changes hit the server as they happen, while the
|
||||
fields wait for Save. Saying so is the cheapest way to stop
|
||||
Cancel reading as "undo everything". -->
|
||||
<p class="text-xs text-muted-foreground">Cover and file changes apply immediately</p>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||
<Button type="submit" form={METADATA_FORM_ID}>Save changes</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
{/key}
|
||||
{/if}
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FileDropZone,
|
||||
type FileDropZoneProps,
|
||||
displaySize
|
||||
} from '$lib/components/ui/file-drop-zone/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index';
|
||||
import BookImage from '$lib/components/view/book-image.svelte';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { updateBookCover } from '$lib/api';
|
||||
import type { Book } from '$lib/schema';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { X } from '@lucide/svelte';
|
||||
import BookImage from '$lib/components/view/book-image.svelte';
|
||||
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone/index.js';
|
||||
|
||||
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
|
||||
let { book }: { book: Book } = $props();
|
||||
|
||||
let formEl = $state<HTMLFormElement>();
|
||||
let coverImagePreview = $state(`/api/${book.cover_image}`);
|
||||
let autoUploadOnDrop = $state(true);
|
||||
// Seeded once per mount — see the key in edit-book.svelte
|
||||
let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
updateBookCover.fields.file.set(uploadedFiles[0]);
|
||||
updateCoverPreview();
|
||||
if (autoUploadOnDrop && updateBookCover.fields.file.value()) {
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
};
|
||||
|
||||
function updateCoverPreview() {
|
||||
@@ -35,14 +25,14 @@
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
coverImagePreview = reader.result;
|
||||
coverImagePreview = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
||||
toast.error(`${file.name} was not used`, { description: reason });
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
@@ -53,65 +43,40 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<form
|
||||
bind:this={formEl}
|
||||
{...updateBookCover.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
form.reset();
|
||||
open = false;
|
||||
toast.success('Updated book cover!');
|
||||
} catch (error) {
|
||||
console.error('Failed to update book cover: ', error);
|
||||
toast.error('Failed to update cover.');
|
||||
}
|
||||
})}
|
||||
enctype="multipart/form-data"
|
||||
class="grid grid-cols-[1fr_2fr] gap-4 p-6"
|
||||
>
|
||||
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
|
||||
<section class="flex min-w-0 flex-col gap-2">
|
||||
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Cover</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<BookImage src={coverImagePreview} class="w-64 rounded" />
|
||||
</div>
|
||||
<BookImage src={coverImagePreview} class="w-full rounded-md border" />
|
||||
|
||||
<form
|
||||
bind:this={formEl}
|
||||
{...updateBookCover.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
form.reset();
|
||||
// Deliberately does not close the dialog. The cover is one panel of a
|
||||
// larger form now, and closing here would throw away metadata edits
|
||||
// the reader has not saved yet.
|
||||
toast.success('Cover updated');
|
||||
} catch (error) {
|
||||
console.error('Failed to update book cover: ', error);
|
||||
toast.error('Failed to update the cover');
|
||||
}
|
||||
})}
|
||||
enctype="multipart/form-data"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
|
||||
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
accept=".jpeg,.jpg,.png,.webp,image/*"
|
||||
label="Only JPEG, PNG, and WEBP images supported"
|
||||
label="Replace cover"
|
||||
sublabel="JPEG, PNG or WEBP"
|
||||
maxFiles={1}
|
||||
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
|
||||
/>
|
||||
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if updateBookCover.fields.file.value()}
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<span>{updateBookCover.fields.file.value().name}</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>{displaySize(updateBookCover.fields.file.value().size)}</span
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
updateBookCover.fields.file.set(undefined);
|
||||
coverImagePreview = `/api/${book.cover_image}`;
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center space-x-2">
|
||||
<Switch bind:checked={autoUploadOnDrop} />
|
||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
||||
<Button type="submit" class="ml-auto w-fit" disabled={!updateBookCover.fields.file.value()}>Upload</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -1,98 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { uploadBookFiles } from '$lib/api';
|
||||
import {
|
||||
displaySize,
|
||||
FileDropZone,
|
||||
type FileDropZoneProps
|
||||
} from '$lib/components/ui/file-drop-zone';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
|
||||
import { Switch } from '$lib/components/ui/switch/index';
|
||||
import { X } from '@lucide/svelte';
|
||||
|
||||
import type { Book } from '$lib/schema';
|
||||
import { tick } from 'svelte';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Download, Trash2 } from '@lucide/svelte';
|
||||
|
||||
import { uploadBookFiles } from '$lib/api';
|
||||
import type { Book, BookFile } from '$lib/schema';
|
||||
import { formatFileSize, getFileType } from '$lib/utils';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone';
|
||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||
|
||||
let { book }: { book: Book } = $props();
|
||||
|
||||
let files = $derived(uploadBookFiles.fields.files.value() ?? []);
|
||||
const bookOps = getBookOperationsState();
|
||||
|
||||
let autoUploadOnDrop = $state(true);
|
||||
/**
|
||||
* The book's files, owned locally.
|
||||
*
|
||||
* `book` is a snapshot handed down from bookOperations rather than a live
|
||||
* query, so invalidating after a delete does not reach it. Keeping the list
|
||||
* here lets the rail reflect an add or a remove straight away. Seeded once per
|
||||
* mount — edit-book.svelte keys the dialog on book.id.
|
||||
*/
|
||||
let files = $state<BookFile[]>(untrack(() => [...book.files]));
|
||||
|
||||
// The field's value is a sparse-ish list until the form settles, so narrow it
|
||||
// before rendering rather than asserting at each use.
|
||||
let pending = $derived(
|
||||
(uploadBookFiles.fields.files.value() ?? []).filter((file): file is File => Boolean(file))
|
||||
);
|
||||
|
||||
let fileToDelete = $state<BookFile>();
|
||||
let deleteFromDisk = $state(true);
|
||||
let confirmOpen = $state(false);
|
||||
let formEl = $state<HTMLFormElement>();
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]);
|
||||
if (autoUploadOnDrop && files.length > 0) {
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploaded) => {
|
||||
uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
};
|
||||
|
||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
||||
toast.error(`${file.name} was not added`, { description: reason });
|
||||
};
|
||||
|
||||
function confirmDelete(file: BookFile) {
|
||||
fileToDelete = file;
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function removeFile() {
|
||||
if (!fileToDelete) return;
|
||||
|
||||
const target = fileToDelete;
|
||||
confirmOpen = false;
|
||||
|
||||
// Drop it from the list first: the request invalidates the books query, but
|
||||
// this dialog holds its own copy of the book and would not see that.
|
||||
files = files.filter((file) => file.id !== target.id);
|
||||
|
||||
await bookOps.deleteBookFiles(book.id, [target.id], deleteFromDisk);
|
||||
fileToDelete = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
{...uploadBookFiles.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
<section class="flex min-w-0 flex-col gap-2">
|
||||
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = uploadBookFiles.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
{#if files.length === 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
No files yet. Add one below so this book can be read or downloaded.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
// Reset the files field
|
||||
uploadBookFiles.fields.files.set([]);
|
||||
toast.success('Files successfully added!');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload files: ', error);
|
||||
toast.error('Failed to upload files');
|
||||
}
|
||||
})}
|
||||
bind:this={formEl}
|
||||
enctype="multipart/form-data"
|
||||
class="flex w-full flex-col gap-2 p-4"
|
||||
>
|
||||
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
|
||||
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
||||
/>
|
||||
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each files as file, idx}
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
uploadBookFiles.fields.files.set([
|
||||
...Array.from(files).slice(0, idx),
|
||||
...Array.from(files).slice(idx + 1)
|
||||
]);
|
||||
}}
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#each files as file (file.id)}
|
||||
<li class="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
<span
|
||||
class="rounded-sm bg-accent px-1.5 py-0.5 font-mono text-[9px] font-semibold text-accent-foreground"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{getFileType(file.filename)}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-row items-center space-x-2">
|
||||
<Switch bind:checked={autoUploadOnDrop} />
|
||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
||||
<Button type="submit" class="ml-auto w-fit">Upload</Button>
|
||||
</div>
|
||||
</form>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs" title={file.filename}>{file.filename}</span>
|
||||
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0"
|
||||
title="Download {file.filename}"
|
||||
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||
>
|
||||
<Download class="size-3.5" />
|
||||
<span class="sr-only">Download {file.filename}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||
title="Remove {file.filename}"
|
||||
onclick={() => confirmDelete(file)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
<span class="sr-only">Remove {file.filename}</span>
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#each pending as file (file.name)}
|
||||
<li
|
||||
class="flex items-center gap-2 rounded-md border border-dashed bg-background p-2 text-muted-foreground"
|
||||
>
|
||||
<Spinner class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs">{file.name}</span>
|
||||
<span class="block font-mono text-[10px] tabular-nums">Uploading…</span>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<form
|
||||
bind:this={formEl}
|
||||
{...uploadBookFiles.enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
const issues = uploadBookFiles.fields.allIssues();
|
||||
if (issues && issues.length > 0) return;
|
||||
|
||||
// The endpoint answers with the updated book, so the new files come
|
||||
// back with their ids rather than having to be guessed at.
|
||||
files = uploadBookFiles.result?.files ?? files;
|
||||
uploadBookFiles.fields.files.set([]);
|
||||
toast.success('Files added');
|
||||
} catch (error) {
|
||||
console.error('Failed to add files: ', error);
|
||||
toast.error('Failed to add files');
|
||||
}
|
||||
})}
|
||||
enctype="multipart/form-data"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
|
||||
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
|
||||
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||
label="Add a file"
|
||||
sublabel="EPUB, PDF or MOBI"
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<AlertDialog.Root bind:open={confirmOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Remove {fileToDelete?.filename}?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This cannot be undone. The other files on this book are not affected.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<!-- The API takes these as separate outcomes: drop the record, or drop the
|
||||
record and the file on disk. Leaving it implicit would mean deleting
|
||||
someone's only copy without saying so. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="delete-from-disk" bind:checked={deleteFromDisk} />
|
||||
<Field.Label for="delete-from-disk" class="font-normal">
|
||||
Also delete the file from the filesystem
|
||||
</Field.Label>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={removeFile}>
|
||||
Remove
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { untrack } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Minus, Plus } from '@lucide/svelte';
|
||||
|
||||
import { updateBookMetadata } from '$lib/api';
|
||||
import type { Book } from '$lib/schema';
|
||||
import { untrack } from 'svelte';
|
||||
import { Minus, Plus } from '@lucide/svelte';
|
||||
|
||||
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
|
||||
let authors = $state(book.authors.map((author) => author.name) || []);
|
||||
let tags = $state(book.tags.map((tag) => tag.name) || []);
|
||||
let identifierKeys = $state(Object.keys(book.identifiers));
|
||||
let identifierValues = $state(Object.values(book.identifiers));
|
||||
let {
|
||||
book,
|
||||
open = $bindable(),
|
||||
/** Lets the dialog footer own the submit button via the `form` attribute. */
|
||||
formId
|
||||
}: { book: Book; open: boolean; formId: string } = $props();
|
||||
|
||||
// Seeded once per mount; edit-book.svelte keys this form on book.id so a
|
||||
// different book gets a fresh form rather than the previous book's values.
|
||||
let authors = $state(untrack(() => book.authors.map((author) => author.name) || []));
|
||||
let tags = $state(untrack(() => book.tags.map((tag) => tag.name) || []));
|
||||
let identifierKeys = $state(untrack(() => Object.keys(book.identifiers)));
|
||||
let identifierValues = $state(untrack(() => Object.values(book.identifiers)));
|
||||
|
||||
function handleAddIdentifier() {
|
||||
// Add empty strings to both arrays
|
||||
identifierKeys = [...identifierKeys, ''];
|
||||
identifierValues = [...identifierValues, ''];
|
||||
}
|
||||
@@ -86,197 +92,187 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class="w-full ">
|
||||
<form
|
||||
{...updateBookMetadata.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = updateBookMetadata.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
open = false;
|
||||
book = book;
|
||||
toast.success('Updated book metadata!');
|
||||
} catch (error) {
|
||||
console.error('Error occurred updating book metadata: ', error);
|
||||
toast.error('Failed to update book metadata.');
|
||||
}
|
||||
})}
|
||||
{#snippet groupHeading(label: string)}
|
||||
<h3
|
||||
class="col-span-full mt-2 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
|
||||
>
|
||||
<Card.Content>
|
||||
<Field.Set>
|
||||
<Field.Group class="flex flex-col">
|
||||
<!-- Book ID field -->
|
||||
<Field.Field class="hidden">
|
||||
<Field.Label for="book_id">Book ID</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.book_id.as('text')} />
|
||||
{#each updateBookMetadata.fields.book_id.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
{label}
|
||||
</h3>
|
||||
{/snippet}
|
||||
|
||||
<!-- Title field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="title">Title</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.title.as('text')} />
|
||||
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<form
|
||||
id={formId}
|
||||
{...updateBookMetadata.enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
<!-- Subtitle field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="subtitle">Subtitle</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
||||
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
// Check if there are any validation issues
|
||||
const issues = updateBookMetadata.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="grid grid-cols-[3fr_1fr] gap-2">
|
||||
<!-- Series field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="series">Series</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.series.as('text')} />
|
||||
{#each updateBookMetadata.fields.series.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
open = false;
|
||||
toast.success('Updated book metadata!');
|
||||
} catch (error) {
|
||||
console.error('Error occurred updating book metadata: ', error);
|
||||
toast.error('Failed to update book metadata.');
|
||||
}
|
||||
})}
|
||||
class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
|
||||
>
|
||||
<input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
|
||||
|
||||
<!-- Series position field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="series_position">Series position</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.series_position.as('text')} />
|
||||
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
{@render groupHeading('Identity')}
|
||||
|
||||
<!-- Authors field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="authors">Authors</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={authors}
|
||||
validate={validateTagsInput}
|
||||
placeholder="Add an author"
|
||||
class="min-h-10 p-2 text-sm"
|
||||
/>
|
||||
{#each authors as author}
|
||||
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
|
||||
{/each}
|
||||
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="title">Title</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.title.as('text')} />
|
||||
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Tags field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="tags">Tags</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
validate={validateTagsInput}
|
||||
placeholder="Add a tag"
|
||||
class="min-h-10 p-2 text-sm"
|
||||
/>
|
||||
{#each tags as tag}
|
||||
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
|
||||
{/each}
|
||||
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="subtitle">Subtitle</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
||||
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Description field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="description">Description</Field.Label>
|
||||
<Textarea {...updateBookMetadata.fields.description.as('text')} />
|
||||
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="edition">Edition</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
||||
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Identifier fields -->
|
||||
<Field.Field>
|
||||
<Field.Label for="identifiers">Identifiers</Field.Label>
|
||||
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2">
|
||||
{#each identifierKeys as _, idx}
|
||||
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." />
|
||||
<Input bind:value={identifierValues[idx]} placeholder="Value..." />
|
||||
<Field.Field>
|
||||
<Field.Label for="series">Series</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.series.as('text')} />
|
||||
{#each updateBookMetadata.fields.series.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}>
|
||||
<Minus />
|
||||
</Button>
|
||||
{/each}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Field.Field>
|
||||
<Field.Label for="series_position">No.</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.series_position.as('text')} />
|
||||
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<Button variant="outline" onclick={() => handleAddIdentifier()}>
|
||||
<Plus />
|
||||
Add Identifier
|
||||
</Button>
|
||||
<Field.Field>
|
||||
<Field.Label for="language">Language</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.language.as('text')} />
|
||||
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
|
||||
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
|
||||
</div>
|
||||
</Field.Field>
|
||||
{@render groupHeading('People and subjects')}
|
||||
|
||||
<!-- Publisher field -->
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
<Field.Field>
|
||||
<Field.Label for="publisher">Publisher</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
||||
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="authors">Authors</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={authors}
|
||||
validate={validateTagsInput}
|
||||
placeholder="Add an author"
|
||||
class="min-h-10 p-2 text-sm"
|
||||
/>
|
||||
{#each authors as author}
|
||||
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
|
||||
{/each}
|
||||
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Published date field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="published_date">Date published</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
||||
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="tags">Tags</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
validate={validateTagsInput}
|
||||
placeholder="Add a tag"
|
||||
class="min-h-10 p-2 text-sm"
|
||||
/>
|
||||
{#each tags as tag}
|
||||
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
|
||||
{/each}
|
||||
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
|
||||
<!-- Pages field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="pages">Pages</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
||||
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
{@render groupHeading('Publication')}
|
||||
|
||||
<!-- Language field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="language">Language</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.language.as('text')} />
|
||||
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="publisher">Publisher</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
||||
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Edition field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="edition">Edition</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
||||
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Card.Content>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Field.Field>
|
||||
<Field.Label for="published_date">Published</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
||||
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Submit button -->
|
||||
<Card.Footer class="flex-col gap-2 pt-6">
|
||||
<Button type="submit" class="w-full">Save</Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
</Card.Root>
|
||||
<Field.Field>
|
||||
<Field.Label for="pages">Pages</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
||||
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="identifiers">Identifiers</Field.Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each identifierKeys as _, idx}
|
||||
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
|
||||
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
|
||||
<Input bind:value={identifierValues[idx]} placeholder="Value" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Remove identifier"
|
||||
onclick={() => handleRemoveIdentifier(idx)}
|
||||
>
|
||||
<Minus />
|
||||
<span class="sr-only">Remove identifier</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<Button type="button" variant="outline" class="w-fit" onclick={handleAddIdentifier}>
|
||||
<Plus />
|
||||
Add identifier
|
||||
</Button>
|
||||
|
||||
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
{@render groupHeading('Description')}
|
||||
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="description" class="sr-only">Description</Field.Label>
|
||||
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
|
||||
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</form>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...$$props}
|
||||
><g fill="none"
|
||||
><path
|
||||
fill="#01579B"
|
||||
d="m29.508 25.58l-11.435 5.17c-.706.332-1.44.3-2.116-.09L1.524 23.33c-.413-.265-.536-.652-.51-.922c.025-.27.087-.563.812-.773l1.07-.395l14.48 7.893l10.29-4.206z"
|
||||
/><path
|
||||
fill="#F5F5F5"
|
||||
d="M17.935 29.923a1.99 1.99 0 0 1-1.815-.065l-14.093-7.1a.395.395 0 0 1-.14-.558c.31-.512.88-2.133-.06-3.477l15.916 7.662z"
|
||||
/><path
|
||||
fill="#94C6D6"
|
||||
d="m28.898 24.995l-10.963 4.927c-.362.158-1.085.438-1.918-.122c.658.047 1.12-.225 1.358-.668c.233-.43.163-1.135-.12-1.532c-.172-.24-.635-.623-.837-.838l11.857-4.637c1.05-.433 2.035.215 2.193 1.003c.174.89-.96 1.617-1.57 1.867"
|
||||
/><path
|
||||
fill="#01579B"
|
||||
d="m29.445 21.74l-11.317 5.05a2.67 2.67 0 0 1-2.193-.1l-14.46-7.345a.94.94 0 0 1-.43-.525c-.135-.437.035-.988.548-1.163l15.67 7.988l10.73-4.593z"
|
||||
/><path
|
||||
fill="#0091EA"
|
||||
d="m30.298 22.473l-1.233-.448l-2.54.148l-8.395 3.747a2.67 2.67 0 0 1-2.192-.1L1.475 18.477a.438.438 0 0 1 .117-.82l10.423-4.662c.297-.055.602-.022.88.095l14.872 7.09s2.363 1.617 2.53 2.293"
|
||||
/><path
|
||||
fill="#616161"
|
||||
d="M26.383 22.245s1.565-.613 2.794-.558s1.658.918 1.658.918c-.233-1.058-1.325-1.598-1.325-1.598l-16.25-8.182c-.112-.047-.527-.145-1.165.118c-.515.212-2.198 1-2.198 1z"
|
||||
/><path
|
||||
fill="#424242"
|
||||
d="M30.905 22.805c-.117-.467-.408-.967-.943-1.21c-.704-.317-1.71-.235-2.352.1l-1.227.545v.865l1.552-.69c1.51-.672 2.18.335 2.238.573c.24.967-.225 1.527-1.598 2.157l-2.23 1.005v.87l2.565-1.142c1.135-.455 2.43-1.31 1.995-3.073"
|
||||
/><path
|
||||
fill="#01579B"
|
||||
d="M8.253 22.578L3.935 16.61l.677-.302l4.858 6.675zm5.537 2.75l-.77-.61l13.363-2.728v.438l-1.873.71z"
|
||||
/><path
|
||||
fill="#9CCC65"
|
||||
d="m3.7 11.545l16.878-2.82l7.372 8.118a.689.689 0 0 1-.36 1.15l-17.425 3.575z"
|
||||
/><path
|
||||
fill="#689F38"
|
||||
d="m27.59 17.293l-17.305 3.505l-.055.825l17.36-3.56a.686.686 0 0 0 .427-1.058a.67.67 0 0 1-.427.288m.933 3.782a.49.49 0 0 1-.318.74l-15.93 3.23c-.957.197-1.898-.43-1.982-1.405a1.635 1.635 0 0 1 1.297-1.742l15.32-3.44z"
|
||||
/><path
|
||||
fill="#616161"
|
||||
d="m13.898 20.025l-6.345-9.08l-3.62.957c-.838.833-.525 2.2-.525 2.2s5.542 8.895 6.417 10.033s2.153.96 2.153.96l2.157-.435l-.225-4.025z"
|
||||
/><path
|
||||
fill="#424242"
|
||||
d="m13.898 20.085l-3.048.63c-.832.188-.982.97-.982.97L2.51 11.143s-1.047 1.267-.352 2.345l7.667 10.647c.838 1.192 2.153.97 2.153.97l2.157-.435l-.223-3.945z"
|
||||
/><path
|
||||
fill="#B9E4EA"
|
||||
d="M27.563 20.75a.29.29 0 0 1-.205.405L12.125 24.28c-.957.197-1.635-.438-1.6-1.303c.045-1.092.657-1.555 1.467-1.722L27.3 18.127s-.52.585-.123 1.68c.136.378.28.713.386.943"
|
||||
/><path
|
||||
stroke="#424242"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width=".518"
|
||||
d="M11.303 20.925L4.775 11.69"
|
||||
/><path fill="#424242" d="m11.815 16.987l-8.395-4.23l-.34.67l9.7 4.943z" /><path
|
||||
fill="#689F38"
|
||||
d="m27.198 16.008l-.616-.675l-9.457 4.34l-5.31-2.628l.905 1.295l3.518 1.797l2.62-.402z"
|
||||
/><path
|
||||
fill="#C62828"
|
||||
d="m29.505 14.338l-11.432 5.17c-.706.332-1.44.3-2.116-.09l-14.434-7.33c-.413-.265-.536-.653-.51-.923c.025-.27.087-.562.812-.772l.678-.25l14.83 7.277l12.042-4.983z"
|
||||
/><path
|
||||
fill="#F5F5F5"
|
||||
d="M17.933 18.68a1.99 1.99 0 0 1-1.815-.065l-14.093-7.1a.395.395 0 0 1-.14-.558c.31-.512.88-2.132-.06-3.477l15.56 7.915z"
|
||||
/><path
|
||||
fill="#94C6D6"
|
||||
d="M28.895 13.753L17.933 18.68c-.363.157-1.085.438-1.918-.122c.657.047 1.12-.226 1.357-.668c.233-.43.163-1.135-.12-1.532c-.172-.24-.634-.623-.837-.838l11.858-4.637c1.05-.433 2.035.215 2.192 1.002c.175.89-.96 1.617-1.57 1.867"
|
||||
/><path
|
||||
fill="#C62828"
|
||||
d="m29.445 10.498l-11.317 5.05a2.67 2.67 0 0 1-2.193-.1L1.472 8.102a.9.9 0 0 1-.447-.54c-.108-.405.032-.938.565-1.148l13.253-2.807z"
|
||||
/><path
|
||||
fill="#F44336"
|
||||
d="m30.295 11.23l-1.233-.448l-2.54.148l-8.394 3.747a2.67 2.67 0 0 1-2.193-.1L1.472 7.232c-.372-.19-.24-.692.118-.82l10.425-4.66c.297-.055.602-.022.88.095l14.872 7.09s2.36 1.615 2.528 2.293"
|
||||
/><path
|
||||
fill="#616161"
|
||||
d="M26.383 11s1.302-.457 2.532-.402s1.922.762 1.922.762c-.252-1.13-1.325-1.598-1.325-1.598L13.263 1.58c-.112-.048-.527-.145-1.165.117a99 99 0 0 0-2.197 1z"
|
||||
/><path fill="#424242" d="M27.87 10.465L11.243 2.077l.55-.247l16.91 8.475z" /><path
|
||||
fill="#424242"
|
||||
d="M30.903 11.563c-.118-.468-.316-.92-.873-1.155c-.713-.3-1.363-.363-2.422.045l-1.228.545v.865l1.553-.69c.787-.37 1.947-.29 2.237.572c.317.945-.225 1.528-1.598 2.158l-2.23 1.005v.87l2.566-1.143c1.137-.455 2.432-1.31 1.994-3.072"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { SVGAttributes } from 'svelte/elements';
|
||||
|
||||
let { ...rest }: SVGAttributes<SVGElement> = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Drawn in currentColor and stroked at lucide's weight so the mark sits with
|
||||
the rest of the sidebar icons and picks up the theme's sidebar foreground.
|
||||
Intrinsic size matches its rendered size — see app-sidebar.svelte.
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{...rest}
|
||||
>
|
||||
<path d="M4 5.4A2.4 2.4 0 0 1 6.4 3H20v15.2H6.4A2.4 2.4 0 0 0 4 20.6z" />
|
||||
<path d="M8.6 3v15.2" />
|
||||
<path d="M12.4 7.6h4" />
|
||||
<path d="M12.4 11.2h4" />
|
||||
</svg>
|
||||
@@ -1,45 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
import UnjsDb0 from '$lib/components/icons/UnjsDb0.svelte';
|
||||
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
|
||||
|
||||
import NavMain from './nav-main.svelte';
|
||||
import NavUser from './nav-user.svelte';
|
||||
import LibrarySwitcher from './library-switcher.svelte';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
collapsible = 'icon',
|
||||
user,
|
||||
...restProps
|
||||
}: ComponentProps<typeof Sidebar.Root> = $props();
|
||||
}: ComponentProps<typeof Sidebar.Root> & { user: { email: string } } = $props();
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
|
||||
// Keep components out of these objects. `<header.icon />` is a *dynamic*
|
||||
// component: every time the $derived object is recomputed — on hydration
|
||||
// when LibraryState picks up previousLibraryId, and on every navigation for
|
||||
// the footer — Svelte re-evaluates the expression and can remount the SVG,
|
||||
// which reads as a flash and shifts layout. Referencing the component
|
||||
// directly in the markup keeps it static.
|
||||
const header = $derived({
|
||||
title: 'chitai',
|
||||
icon: UnjsDb0,
|
||||
url: `/library/${libraryState.activeLibrary!.id}`
|
||||
});
|
||||
|
||||
const footer = $derived({
|
||||
title: 'Settings',
|
||||
url: '/settings',
|
||||
icon: SettingsIcon,
|
||||
isActive: page.url.pathname.startsWith('/settings')
|
||||
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Root variant="floating" {collapsible} {...restProps} class="ml-1 py-3">
|
||||
<Sidebar.Header class="h-(--header-height)">
|
||||
<Sidebar.MenuButton>
|
||||
<!--
|
||||
The mark is 28px where the nav icons are 16px, and both start at the same
|
||||
8px padding — so its centre sits 6px right of theirs. -ml-1.5 is that 6px,
|
||||
which lines the logo up with the nav in both expanded and collapsed states.
|
||||
-->
|
||||
<Sidebar.MenuButton class="mt-1 -ml-1.5">
|
||||
{#snippet child({ props })}
|
||||
<!-- header.url is built with resolve(); the rule cannot trace the variable. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={header.url} {...props}>
|
||||
<header.icon class="mr-3 scale-175" />
|
||||
<span class="text-xl font-semibold">{header.title}</span>
|
||||
<ChitaiMark class="mr-3 size-7!" />
|
||||
<span class="font-serif text-xl tracking-tight">{header.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
@@ -50,15 +56,6 @@
|
||||
<NavMain />
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.MenuButton class="mb-2" isActive={footer.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<a href={footer.url} {...props}>
|
||||
{#if footer.icon}
|
||||
<footer.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md pl-2">{footer.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
<NavUser {user} />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
<span class="ml-1 truncate font-semibold">
|
||||
{libraryState.activeLibrary!.name}
|
||||
</span>
|
||||
<span class="ml-1 truncate font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{libraryState.activeLibrary!.total ?? 0} books
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon class="mr-2 ml-auto" />
|
||||
</Sidebar.MenuButton>
|
||||
|
||||
@@ -1,102 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
const bookshelfState = getBookshelfState();
|
||||
const libraryState = getLibraryState();
|
||||
const bookshelfState = getBookshelfState();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let items = $derived(
|
||||
[
|
||||
{
|
||||
title: 'Home',
|
||||
url: `/library/${libraryState.activeLibrary?.id}`,
|
||||
icon: House
|
||||
},
|
||||
{
|
||||
title: 'Library',
|
||||
url: `/library/${libraryState.activeLibrary?.id}/view`,
|
||||
icon: LibraryBig
|
||||
},
|
||||
{
|
||||
title: 'Shelves',
|
||||
url: '#',
|
||||
icon: Rows3,
|
||||
shelves: []
|
||||
}
|
||||
].map((item) => ({
|
||||
...item,
|
||||
isActive: page.url.pathname === item.url
|
||||
}))
|
||||
);
|
||||
// Owned here so the collapsed rail can force it open when it expands the
|
||||
// sidebar. Previously open={isActive}, which was always false — the Shelves
|
||||
// item has no route of its own, so pathname never matched.
|
||||
let shelvesOpen = $state(false);
|
||||
|
||||
// Deliberately a plain const, not $derived. These objects hold component
|
||||
// references, and `<item.icon />` is a dynamic component — if the array were
|
||||
// rebuilt on every navigation the icons would remount, flashing and shifting
|
||||
// layout. Only the url and active state need to be reactive, so they are
|
||||
// computed per-item in the markup instead.
|
||||
//
|
||||
// Active state is matched on route id rather than pathname. resolve() returns
|
||||
// an absolute path on the client but a relative one during SSR, so comparing
|
||||
// it to page.url.pathname would be false on the server and true after
|
||||
// hydration — the highlight would flash in.
|
||||
const items = [
|
||||
{
|
||||
title: 'Home',
|
||||
icon: House,
|
||||
routeId: '/(root)/(library)/library/[libraryId]',
|
||||
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
|
||||
},
|
||||
{
|
||||
title: 'Library',
|
||||
icon: LibraryBig,
|
||||
routeId: '/(root)/(library)/library/[libraryId]/view',
|
||||
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
|
||||
},
|
||||
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#snippet shelvesTrigger(item: { title: string; icon: typeof House })}
|
||||
<item.icon class="scale-125" />
|
||||
<span class="text-md ml-2">{item.title}</span>
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
{#if 'shelves' in item}
|
||||
<Collapsible.Root open={item.isActive} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
|
||||
{#if item.icon}
|
||||
<item.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md ml-2">{item.title}</span>
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub class="w-full">
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href={`/library/${libraryState.activeLibrary!.id}/view?shelves=${shelf.id}`}
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="scale-90 font-semibold bg-sidebar-primary text-sidebar-primary-foreground mr-1">
|
||||
{shelf.total}
|
||||
</Badge>
|
||||
<span>{shelf.title}</span>
|
||||
|
||||
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{:else}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={item.isActive} tooltipContent={item.title} class="h-10">
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md pl-2">{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
{@const url = item.path(libraryState.activeLibrary?.id)}
|
||||
{@const isActive = item.routeId !== null && page.route.id === item.routeId}
|
||||
{#if 'shelves' in item}
|
||||
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
{#if sidebar.state === 'collapsed'}
|
||||
<!--
|
||||
Sidebar.MenuSub is group-data-[collapsible=icon]:hidden, so
|
||||
toggling in the rail opens a list nobody can see. Expand the
|
||||
sidebar and open the section instead of toggling.
|
||||
-->
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent={item.title}
|
||||
class="h-10"
|
||||
onclick={() => {
|
||||
sidebar.setOpen(true);
|
||||
shelvesOpen = true;
|
||||
}}
|
||||
>
|
||||
{@render shelvesTrigger(item)}
|
||||
</Sidebar.MenuButton>
|
||||
{:else}
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
|
||||
{@render shelvesTrigger(item)}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub class="w-full">
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary!.id)
|
||||
})}?shelves={shelf.id}"
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="mr-1 scale-90 bg-sidebar-primary font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
{shelf.total}
|
||||
</Badge>
|
||||
<span>{shelf.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{:else}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton {isActive} tooltipContent={item.title} class="h-10">
|
||||
{#snippet child({ props })}
|
||||
<!-- item.path() calls resolve(); the rule cannot trace the variable. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md pl-2">{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import { logout } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
|
||||
let { user }: { user: { email: string } } = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// No display name on the account yet, so the local part of the address
|
||||
// stands in for one and its first two letters make the avatar.
|
||||
const handle = $derived(user?.email?.split('@')[0] ?? 'Account');
|
||||
const initials = $derived(handle.slice(0, 2).toUpperCase());
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Fallback
|
||||
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
{initials}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{handle}</span>
|
||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
|
||||
>
|
||||
</div>
|
||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Fallback
|
||||
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
{initials}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{handle}</span>
|
||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/account'))}>
|
||||
<SettingsIcon class="size-4" />
|
||||
Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/appearance'))}>
|
||||
<PaletteIcon class="size-4" />
|
||||
Appearance
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await logout();
|
||||
await goto(resolve('/login'));
|
||||
}}
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
Log out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Command from '$lib/components/ui/command/index';
|
||||
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
||||
import * as InputGroup from '$lib/components/ui/input-group/index.js';
|
||||
@@ -9,6 +10,7 @@
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import type { PaginatedResponse, Book } from '$lib/schema';
|
||||
import BookImage from '../view/book-image.svelte';
|
||||
import GeneratedCover from '../view/generated-cover.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
@@ -59,14 +61,14 @@
|
||||
|
||||
<svelte:document onkeydown={handleKeydown} />
|
||||
|
||||
<form>
|
||||
<div class="flex w-full max-w-xs flex-col gap-6">
|
||||
<form class="min-w-0 flex-1">
|
||||
<div class="flex w-full max-w-xl flex-col">
|
||||
<InputGroup.Root>
|
||||
<InputGroup.Input placeholder="Search..." onclick={handleClick} />
|
||||
<InputGroup.Input placeholder="Search books..." onclick={handleClick} />
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon />
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<InputGroup.Addon align="inline-end" class="hidden sm:flex">
|
||||
<Kbd.Root>Ctrl</Kbd.Root>+
|
||||
<Kbd.Root>k</Kbd.Root>
|
||||
</InputGroup.Addon>
|
||||
@@ -97,25 +99,33 @@
|
||||
<Command.Item
|
||||
value={String(book.id)}
|
||||
onSelect={() => {
|
||||
goto(`/book/${book.id}`);
|
||||
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) }));
|
||||
open = false;
|
||||
}}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<div class="hover:bg-base-200 flex gap-4 p-3">
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="w-24 rounded object-cover shadow-lg"
|
||||
/>
|
||||
{#if book.cover_image}
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="w-24 rounded object-cover shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="aspect-9/12 w-24 shrink-0 overflow-hidden rounded shadow-lg">
|
||||
<GeneratedCover {book} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-top flex flex-col">
|
||||
<span class="text-lg font-medium">{book.title}</span>
|
||||
<span class="font-serif text-lg">{book.title}</span>
|
||||
<span class=" text-md">{book.subtitle}</span>
|
||||
{#if book.authors.length > 0}
|
||||
<span class="line-clamp-1 w-full text-sm text-muted-foreground">
|
||||
by
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary!.id)
|
||||
})}?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
|
||||
@@ -11,21 +11,18 @@
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 flex w-full items-center border-b bg-background">
|
||||
<div class="my-2 flex h-(--header-height) w-full items-center gap-2 px-4">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="flex gap-3">
|
||||
<Button class="size-8" variant="ghost" size="icon" onclick={sidebar.toggle}>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mr-2 h-(--header-height) border" />
|
||||
</div>
|
||||
<div class="my-2 flex h-(--header-height) w-full items-center gap-3 px-4">
|
||||
<Button class="size-8 shrink-0" variant="ghost" size="icon" onclick={sidebar.toggle}>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mr-1 h-6" />
|
||||
|
||||
<SearchForm />
|
||||
<!-- Search takes the free space rather than sitting at a fixed width -->
|
||||
<SearchForm />
|
||||
|
||||
<div class="flex w-24 items-center gap-4">
|
||||
<UploadButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<UploadButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { toggleMode, mode } from 'mode-watcher';
|
||||
import { toggleMode } from 'mode-watcher';
|
||||
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||
@@ -9,25 +9,26 @@
|
||||
let { class: className = '' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Which icon shows is decided by CSS off the `dark` class, not by reading
|
||||
`mode.current` in the markup. The server cannot know the mode, so a
|
||||
`{#if mode.current === 'light'}` renders the wrong branch during SSR and
|
||||
visibly swaps on hydration.
|
||||
-->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
onclick={toggleMode}
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110 {className}"
|
||||
>
|
||||
{#if mode.current === 'light'}
|
||||
<Moon class="scale-110" />
|
||||
{:else}
|
||||
<Sun class="scale-110" />
|
||||
{/if}
|
||||
<Moon class="scale-110 dark:hidden" />
|
||||
<Sun class="hidden scale-110 dark:block" />
|
||||
<span class="sr-only">Toggle light and dark mode</span>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
{#if mode.current === 'light'}
|
||||
<p>Dark Mode</p>
|
||||
{:else}
|
||||
<p>Light Mode</p>
|
||||
{/if}
|
||||
<p class="dark:hidden">Dark Mode</p>
|
||||
<p class="hidden dark:block">Light Mode</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
<script>
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import BooksUpload from '$lib/components/forms/books-upload.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
|
||||
const bookOps = getBookOperationsState();
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
onclick={async () => {
|
||||
bookOps.uploadDialogOpen = true;
|
||||
}}
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110"
|
||||
>
|
||||
<Upload class="scale-110" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Upload Book</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<!-- Labelled, so no tooltip: the button already says what it does. -->
|
||||
<Button onclick={() => (bookOps.uploadDialogOpen = true)}>
|
||||
<Upload class="size-4" />
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
<BooksUpload bind:open={bookOps.uploadDialogOpen} />
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import { ChevronDown, CircleAlert, RotateCcw, X } from '@lucide/svelte';
|
||||
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
|
||||
const queue = getUploadQueueState();
|
||||
|
||||
// A clean run clears itself after a few seconds, so it needs to leave rather
|
||||
// than blink out. Nothing to animate for anyone who asked not to see it.
|
||||
const motion = $derived(prefersReducedMotion.current ? 0 : 200);
|
||||
|
||||
const percent = $derived(queue.total === 0 ? 0 : Math.round((queue.settled / queue.total) * 100));
|
||||
|
||||
const heading = $derived.by(() => {
|
||||
const noun = queue.total === 1 ? 'book' : 'books';
|
||||
if (queue.active) return `Adding ${queue.settled} of ${queue.total} ${noun}`;
|
||||
if (queue.failed === 0) return `Added ${queue.done} ${noun}`;
|
||||
if (queue.done === 0) return `Couldn't add ${queue.failed} ${noun}`;
|
||||
return `Added ${queue.done}, ${queue.failed} failed`;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if queue.total > 0}
|
||||
<!--
|
||||
Docked rather than a toast. This runs for minutes and carries a row per
|
||||
book, which a notification surface is not built to hold still for.
|
||||
-->
|
||||
<aside
|
||||
aria-label="Uploads"
|
||||
transition:fly={{ y: 12, duration: motion }}
|
||||
onmouseenter={() => queue.hold()}
|
||||
onmouseleave={() => queue.release()}
|
||||
onfocusin={() => queue.hold()}
|
||||
onfocusout={() => queue.release()}
|
||||
class="fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border bg-card shadow-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2 border-b px-3 py-2">
|
||||
{#if queue.active}
|
||||
<Spinner class="size-4 shrink-0" />
|
||||
{:else if queue.failed > 0}
|
||||
<CircleAlert class="size-4 shrink-0 text-destructive" />
|
||||
{/if}
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{heading}</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0"
|
||||
onclick={() => (queue.collapsed = !queue.collapsed)}
|
||||
>
|
||||
<ChevronDown class="size-4 transition-transform {queue.collapsed ? '' : 'rotate-180'}" />
|
||||
<span class="sr-only">{queue.collapsed ? 'Show' : 'Hide'} the list</span>
|
||||
</Button>
|
||||
|
||||
<!-- Only once nothing is in flight: dismissing mid-run would suggest it
|
||||
stopped the upload, which it does not. -->
|
||||
{#if !queue.active}
|
||||
<Button variant="ghost" size="icon" class="size-7 shrink-0" onclick={() => queue.dismiss()}>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Dismiss</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="h-1 bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-[width] duration-300"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{#if !queue.collapsed}
|
||||
<ul class="flex max-h-64 flex-col divide-y overflow-y-auto">
|
||||
{#each queue.jobs as job (job.id)}
|
||||
<li class="flex min-w-0 items-center gap-2 px-3 py-2">
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm" title={job.label}>{job.label}</span>
|
||||
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{job.error ?? formatFileSize(job.size)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="shrink-0 font-mono text-[10px] tracking-wider uppercase
|
||||
{job.status === 'done' ? 'text-primary' : ''}
|
||||
{job.status === 'failed' ? 'text-destructive' : ''}
|
||||
{job.status !== 'done' && job.status !== 'failed' ? 'text-muted-foreground' : ''}"
|
||||
>
|
||||
{#if job.status === 'uploading'}
|
||||
Adding
|
||||
{:else if job.status === 'done'}
|
||||
Done
|
||||
{:else if job.status === 'failed'}
|
||||
Failed
|
||||
{:else}
|
||||
Queued
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if !queue.active && queue.failed > 0}
|
||||
<div class="border-t p-2">
|
||||
<Button variant="outline" size="sm" class="w-full" onclick={() => queue.retryFailed()}>
|
||||
<RotateCcw class="size-4" />
|
||||
Retry {queue.failed} failed
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -1,27 +0,0 @@
|
||||
<script>
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
||||
|
||||
let { chapters } = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Root>
|
||||
<Sidebar.Header />
|
||||
<Sidebar.Content>
|
||||
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each chapters as chapter}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
{#snippet child({ props })}
|
||||
<span>{chapter.label}</span>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
<Sidebar.Group />
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer />
|
||||
</Sidebar.Root>
|
||||
@@ -1,354 +1,247 @@
|
||||
<script lang="ts">
|
||||
// TODO: Add type hints to the rest of this file
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
PanelLeft,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
TriangleAlert
|
||||
} from '@lucide/svelte';
|
||||
|
||||
import { Book, Rendition } from 'epubjs';
|
||||
|
||||
import '../../../app.css';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index';
|
||||
|
||||
import { ChevronDown, ChevronRight, ChevronLeft } from '@lucide/svelte';
|
||||
import type { FoliateRelocateDetail, FoliateTocItem } from '$foliate/view.js';
|
||||
import { ProgressReporter } from '$lib/reader/progress';
|
||||
import { purgeLegacyLocationCache } from '$lib/reader/legacy-cache';
|
||||
import { setReaderSettingsState } from '$lib/state/reader-settings.svelte';
|
||||
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||
import type { DisplayedLocation } from 'epubjs/types/rendition';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
|
||||
import FoliateView from './foliate-view.svelte';
|
||||
import ReaderSettings from './reader-settings.svelte';
|
||||
import ReaderToc from './reader-toc.svelte';
|
||||
|
||||
let { bookUrl, bookId, initialProgress = 0, initialEpubLoc = null } = $props();
|
||||
let {
|
||||
fileUrl,
|
||||
bookId,
|
||||
filename,
|
||||
title = '',
|
||||
initialProgress = 0,
|
||||
initialEpubLoc = null
|
||||
}: {
|
||||
fileUrl: string;
|
||||
bookId: string | number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
initialProgress?: number;
|
||||
initialEpubLoc?: string | null;
|
||||
} = $props();
|
||||
|
||||
let epubViewer = $state<HTMLElement>();
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let isReaderVisible = $state(false);
|
||||
const settingsState = setReaderSettingsState();
|
||||
|
||||
let hasNextPage = $state(true);
|
||||
let hasPrevPage = $state(false);
|
||||
// Read once: the reader is remounted per file, so these are fixed for its
|
||||
// lifetime, and tracking them would restart the load mid-read.
|
||||
const reporter = new ProgressReporter(untrack(() => bookId));
|
||||
const initialCfi = untrack(() => initialEpubLoc);
|
||||
const initialFraction = untrack(() => initialProgress);
|
||||
|
||||
let book: Book | undefined = $state();
|
||||
let rendition = $state<Rendition>();
|
||||
let chapters = $state([]);
|
||||
|
||||
let isMounted = $state(false);
|
||||
|
||||
let currentLocation = $state(initialEpubLoc);
|
||||
let currentProgress = $state(initialProgress);
|
||||
let file = $state<File>();
|
||||
let loadError = $state<string | null>(null);
|
||||
let isReady = $state(false);
|
||||
|
||||
let toc = $state<FoliateTocItem[]>([]);
|
||||
let activeTocId = $state<number | null>(null);
|
||||
let isSidebarOpen = $state(false);
|
||||
let debounceTimeout = $state<NodeJS.Timeout>();
|
||||
let isSettingsOpen = $state(false);
|
||||
let isFixedLayout = $state(false);
|
||||
|
||||
// Function to update dimensions
|
||||
function updateDimensions() {
|
||||
if (epubViewer) {
|
||||
// Get parent element dimensions
|
||||
const parent = epubViewer.parentElement;
|
||||
let progress = $state(initialFraction);
|
||||
let viewer = $state<ReturnType<typeof FoliateView>>();
|
||||
|
||||
containerWidth = parent?.clientWidth! * 0.9 - 240;
|
||||
containerHeight = window.innerHeight * 0.8;
|
||||
}
|
||||
}
|
||||
const percent = $derived(Math.round(progress * 100));
|
||||
|
||||
// Handle window resize
|
||||
function handleResize() {
|
||||
updateDimensions();
|
||||
if (rendition) {
|
||||
rendition.resize(containerWidth, containerHeight);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetches the book ourselves rather than handing foliate the URL.
|
||||
*
|
||||
* view.open(url) would route through foliate's fetchFile, which names the File
|
||||
* after the URL path — "34", with no extension — and makeBook's CBZ/FB2 checks
|
||||
* are filename-based. Doing it here also keeps the response.ok check: the proxy
|
||||
* answers a failure with a SvelteKit error page, and fetch resolves on a 404,
|
||||
* so without it a missing file surfaced only as an opaque parse error.
|
||||
*/
|
||||
async function loadFile() {
|
||||
loadError = null;
|
||||
isReady = false;
|
||||
file = undefined;
|
||||
|
||||
async function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
await prevPage();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
await nextPage();
|
||||
}
|
||||
}
|
||||
|
||||
async function nextPage() {
|
||||
if (hasNextPage) await rendition!.next();
|
||||
}
|
||||
|
||||
async function prevPage() {
|
||||
if (hasPrevPage) await rendition!.prev();
|
||||
}
|
||||
|
||||
async function setUserBookProgress() {
|
||||
clearTimeout(debounceTimeout);
|
||||
|
||||
debounceTimeout = setTimeout(async () => {
|
||||
await fetch(`/api/books/progress/${bookId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
percentage: currentProgress,
|
||||
epub_cfi: currentLocation,
|
||||
completed: currentProgress === 1
|
||||
})
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const getChapters = async (book: Book) => {
|
||||
await book.ready;
|
||||
// Get spine items (basic chapter structure)
|
||||
const spineItems = book.spine.items.map((item, index) => ({
|
||||
id: item.idref,
|
||||
href: item.href,
|
||||
index: index,
|
||||
label: item.label || `Chapter ${index + 1}`,
|
||||
cfi: book.spine.get(index).cfiBase // Get CFI from spine
|
||||
}));
|
||||
|
||||
// Get table of contents for better labels
|
||||
const toc = await book.loaded.navigation;
|
||||
|
||||
// Combine spine items with TOC information
|
||||
const chapters = toc.toc.map((chapter) => {
|
||||
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
|
||||
return {
|
||||
...spineItem,
|
||||
label: chapter.label || spineItem?.label,
|
||||
subitems: chapter.subitems,
|
||||
href: chapter.href,
|
||||
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
|
||||
};
|
||||
});
|
||||
|
||||
return chapters;
|
||||
};
|
||||
async function navigateToChapter(chapter: any) {
|
||||
try {
|
||||
if (!rendition || !book) return;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`);
|
||||
|
||||
// Ensure book is ready
|
||||
await book.ready;
|
||||
|
||||
// Try different navigation methods
|
||||
if (chapter.href) {
|
||||
await rendition.display(chapter.href);
|
||||
} else {
|
||||
console.error('No valid navigation target found for chapter:', chapter);
|
||||
}
|
||||
file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
|
||||
} catch (error) {
|
||||
console.error('Error navigating to chapter:', error);
|
||||
console.error('Could not download the book', error);
|
||||
loadError = error instanceof Error ? error.message : 'The file could not be downloaded.';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
// Initial setup
|
||||
updateDimensions();
|
||||
function handleRelocate(detail: FoliateRelocateDetail) {
|
||||
progress = detail.fraction;
|
||||
activeTocId = detail.tocItem?.id ?? null;
|
||||
reporter.record(detail.fraction, detail.cfi);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the EPUB file
|
||||
const response = await fetch(bookUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
function navigateToChapter(href: string) {
|
||||
// Deliberately leaves the sidebar open: picking a chapter is usually part of
|
||||
// browsing several, and closing it each time made that tedious.
|
||||
void viewer?.goTo(href);
|
||||
}
|
||||
|
||||
// Create book from array buffer
|
||||
book = new Book();
|
||||
await book.open(arrayBuffer, 'binary');
|
||||
onMount(() => {
|
||||
purgeLegacyLocationCache();
|
||||
reporter.listen();
|
||||
void loadFile();
|
||||
|
||||
// Generate locations if they do not exist in localStorage
|
||||
let existingLocations = localStorage.getItem(`${bookId}-locations`);
|
||||
let locations;
|
||||
if (existingLocations) {
|
||||
locations = JSON.parse(existingLocations);
|
||||
book.locations.load(locations);
|
||||
} else {
|
||||
locations = await book.locations.generate(1600);
|
||||
// Save locations to localStorage
|
||||
localStorage.setItem(`${bookId}-locations`, JSON.stringify(locations));
|
||||
}
|
||||
|
||||
await book.ready;
|
||||
|
||||
// Render the book to the viewer element
|
||||
rendition = book.renderTo('epub-viewer', {
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
spread: 'auto',
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
flow: 'paginated'
|
||||
});
|
||||
|
||||
// Set the key listener on the iframe element
|
||||
let keyListener = async function (e: any) {
|
||||
// Left Key
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
await prevPage();
|
||||
}
|
||||
// Right Key
|
||||
if ((e.keyCode || e.which) == 39) {
|
||||
await nextPage();
|
||||
}
|
||||
};
|
||||
|
||||
// Add resize listener
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Add Key listener
|
||||
rendition.on('keydown', keyListener);
|
||||
|
||||
// Listen to location changes
|
||||
rendition.on('locationChanged', async (location: DisplayedLocation) => {
|
||||
if (!location?.start) return;
|
||||
|
||||
currentLocation = rendition!.currentLocation().start.cfi;
|
||||
currentProgress = book?.locations.percentageFromCfi(currentLocation);
|
||||
|
||||
await setUserBookProgress();
|
||||
|
||||
hasNextPage = !rendition!.location.atEnd;
|
||||
hasPrevPage = !rendition!.location.atStart;
|
||||
});
|
||||
|
||||
chapters = await getChapters(book);
|
||||
|
||||
let initialLocationCfi =
|
||||
currentLocation || book.locations.cfiFromPercentage(currentProgress);
|
||||
|
||||
if (initialLocationCfi) {
|
||||
await rendition.display(initialLocationCfi);
|
||||
} else {
|
||||
await rendition.display();
|
||||
}
|
||||
|
||||
isMounted = true;
|
||||
isReaderVisible = true;
|
||||
} catch (error) {
|
||||
console.error('Error loading EPUB', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
book.destroy();
|
||||
}
|
||||
return () => reporter.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={handleKeydown} />
|
||||
<svelte:head>
|
||||
<title>{title ? `${title} — Chitai` : 'Reader — Chitai'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<Sidebar.Provider bind:open={isSidebarOpen}>
|
||||
<Sidebar.Root class={!isReaderVisible ? 'hidden' : ''}>
|
||||
<Sidebar.Header />
|
||||
<Sidebar.Content>
|
||||
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each chapters as chapter}
|
||||
{#if chapter.subitems.length > 0}
|
||||
<Collapsible.Root class="group/collapsible">
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Sidebar.MenuItem class="min-w-0 flex-1">
|
||||
<Sidebar.MenuButton
|
||||
class="w-full"
|
||||
onclick={async () => await navigateToChapter(chapter)}
|
||||
>
|
||||
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Collapsible.Trigger class="flex-shrink-0 p-2">
|
||||
<ChevronDown
|
||||
class="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
{#each chapter.subitems as subchapter}
|
||||
<Sidebar.MenuSubItem class="min-w-0">
|
||||
<Sidebar.MenuButton
|
||||
class="w-full"
|
||||
onclick={async () => await navigateToChapter(subchapter)}
|
||||
>
|
||||
<span class="block truncate" title={subchapter.label}
|
||||
>{subchapter.label}</span
|
||||
>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else}
|
||||
<Sidebar.MenuItem class="min-w-0">
|
||||
<Sidebar.MenuButton
|
||||
class="w-full"
|
||||
onclick={async () => await navigateToChapter(chapter)}
|
||||
>
|
||||
<span class="block truncate">{chapter.label}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
<Sidebar.Group />
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer />
|
||||
</Sidebar.Root>
|
||||
<main class="flex w-full overflow-hidden">
|
||||
{#if browser}
|
||||
{#if !isReaderVisible}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<Sidebar.Provider bind:open={isSidebarOpen} class="min-h-0">
|
||||
{#if isReady}
|
||||
<ReaderToc {toc} activeId={activeTocId} onnavigate={navigateToChapter} />
|
||||
{/if}
|
||||
|
||||
<main class="flex h-screen w-full flex-col overflow-hidden bg-background">
|
||||
<!-- Reader chrome: somewhere to go back to, what you are reading, how far in -->
|
||||
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
|
||||
onclick={() => (isSidebarOpen = !isSidebarOpen)}
|
||||
disabled={!isReady || toc.length === 0}
|
||||
>
|
||||
<PanelLeft class="size-4" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom"><p>Chapters</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
|
||||
{title}
|
||||
>
|
||||
{title || 'Reader'}
|
||||
</a>
|
||||
|
||||
{#if isReady}
|
||||
<span class="hidden items-center gap-2 sm:flex">
|
||||
<span class="h-1 w-24 overflow-hidden rounded-full bg-muted-foreground/25">
|
||||
<span class="block h-full bg-flag" style="width: {percent}%;"></span>
|
||||
</span>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">{percent}%</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
|
||||
onclick={() => (isSettingsOpen = true)}
|
||||
disabled={!isReady}
|
||||
>
|
||||
<Settings2 class="size-4" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom"><p>Reading settings</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
{#if loadError}
|
||||
<div class="flex flex-1 items-center justify-center p-6">
|
||||
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
|
||||
<TriangleAlert class="size-8 text-destructive" />
|
||||
<h2 class="font-serif text-lg">This book wouldn't open</h2>
|
||||
<p class="text-sm text-muted-foreground">{loadError}</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<Button onclick={loadFile}>
|
||||
<RotateCcw class="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||
class={buttonVariants({ variant: 'outline' })}
|
||||
>
|
||||
Back to book
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !isReady}
|
||||
<div class="flex flex-1 items-center justify-center gap-3">
|
||||
<Spinner />
|
||||
<span class="text-sm text-muted-foreground">Opening…</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}">
|
||||
<div class="flex">
|
||||
<Sidebar.Trigger />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 p-4 {isReady ? '' : 'hidden'}">
|
||||
<!-- The page itself: same colour the book is themed to, so the two
|
||||
meet seamlessly and the border reads as the edge of the sheet. -->
|
||||
<div class="relative h-full overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
{#if file}
|
||||
<FoliateView
|
||||
bind:this={viewer}
|
||||
{file}
|
||||
{initialCfi}
|
||||
{initialFraction}
|
||||
settings={settingsState.settings}
|
||||
onrelocate={handleRelocate}
|
||||
onerror={(message) => (loadError = message)}
|
||||
onready={(detail) => {
|
||||
toc = detail.toc;
|
||||
isFixedLayout = detail.isFixedLayout;
|
||||
isReady = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="mt-[-30px] flex h-full w-full items-center justify-center">
|
||||
<ChevronLeft
|
||||
onclick={prevPage}
|
||||
class={hasPrevPage
|
||||
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
||||
: 'text-muted'}
|
||||
/>
|
||||
<div
|
||||
id="epub-viewer"
|
||||
bind:this={epubViewer}
|
||||
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md"
|
||||
></div>
|
||||
<ChevronRight
|
||||
onclick={nextPage}
|
||||
class={hasNextPage
|
||||
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
||||
: 'text-muted'}
|
||||
/>
|
||||
<!-- Overlaid rather than placed beside the page, so they stay within
|
||||
reach of the text. They sit in foliate's own page margin. -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute top-1/2 left-1 size-10 -translate-y-1/2 opacity-40 transition-opacity hover:opacity-100"
|
||||
onclick={() => viewer?.goBack()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft class="size-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute top-1/2 right-1 size-10 -translate-y-1/2 opacity-40 transition-opacity hover:opacity-100"
|
||||
onclick={() => viewer?.goForward()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight class="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<style>
|
||||
.epub-content {
|
||||
position: relative;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Position separator relative to epub content */
|
||||
@media (min-width: 1209px) {
|
||||
.epub-content:after {
|
||||
/* Calculate position based on content width */
|
||||
--separator-position: calc(var(--content-width, 100%) / 2);
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
border-right: 1px #000 solid;
|
||||
height: 90%;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 5%;
|
||||
opacity: 0.15;
|
||||
box-shadow: -2px 0 15px rgba(0, 0, 0, 1);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<ReaderSettings bind:open={isSettingsOpen} {isFixedLayout} />
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
import type {
|
||||
FoliateLoadDetail,
|
||||
FoliateRelocateDetail,
|
||||
FoliateTarget,
|
||||
FoliateTocItem,
|
||||
View
|
||||
} from '$foliate/view.js';
|
||||
import { createFoliateView, loadFoliate } from '$lib/reader/foliate';
|
||||
import type { ReaderSettings } from '$lib/schema/reader';
|
||||
import { toRendererAttributes } from '$lib/reader/settings';
|
||||
import { buildReaderStyles, readReaderPalette } from '$lib/reader/stylesheet';
|
||||
|
||||
interface ReadyDetail {
|
||||
toc: FoliateTocItem[];
|
||||
isFixedLayout: boolean;
|
||||
rtl: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
file,
|
||||
initialCfi = null,
|
||||
initialFraction = 0,
|
||||
settings,
|
||||
onready,
|
||||
onrelocate,
|
||||
onerror
|
||||
}: {
|
||||
file: File;
|
||||
initialCfi?: string | null;
|
||||
initialFraction?: number;
|
||||
settings: ReaderSettings;
|
||||
onready?: (detail: ReadyDetail) => void;
|
||||
onrelocate?: (detail: FoliateRelocateDetail) => void;
|
||||
onerror?: (message: string) => void;
|
||||
} = $props();
|
||||
|
||||
let container = $state<HTMLDivElement>();
|
||||
|
||||
/** Not $state: nothing in the markup reads it, and it must not be proxied. */
|
||||
let view: View | undefined;
|
||||
let rtl = false;
|
||||
|
||||
// Reactive so the effects below re-run once the book is open — otherwise a
|
||||
// theme flip while it was still loading would never reach the book.
|
||||
let ready = $state(false);
|
||||
let hostWidth = $state(0);
|
||||
|
||||
/**
|
||||
* How many columns the paginator will actually lay out.
|
||||
*
|
||||
* It decides this internally and exposes it only as a custom property inside
|
||||
* its shadow root, so the formula is mirrored here to place the spread
|
||||
* divider. Kept in step with paginator.js's `divisor`.
|
||||
*/
|
||||
const columns = $derived(
|
||||
settings.flow === 'scrolled' || hostWidth === 0
|
||||
? 1
|
||||
: Math.min(settings.maxColumnCount, Math.ceil(hostWidth / settings.maxInlineSize))
|
||||
);
|
||||
|
||||
/**
|
||||
* Where to resume from.
|
||||
*
|
||||
* foliate's resolveNavigation logs and swallows its failures, returning
|
||||
* undefined — and init() then falls through to next(), i.e. page one. So an
|
||||
* epub.js-authored CFI that does not resolve would silently reset the reader.
|
||||
* Resolve it up front and fall back to the percentage the backend has stored
|
||||
* all along, which lands within a page or two.
|
||||
*/
|
||||
function resolveStart(v: View): FoliateTarget | null {
|
||||
if (initialCfi) {
|
||||
const resolved = v.resolveNavigation(initialCfi);
|
||||
if (resolved && Number.isInteger(resolved.index) && resolved.index >= 0) return initialCfi;
|
||||
console.warn('Stored CFI did not resolve; falling back to percentage', initialCfi);
|
||||
}
|
||||
return initialFraction > 0 ? { fraction: initialFraction } : null;
|
||||
}
|
||||
|
||||
function applyAttributes(current: ReaderSettings) {
|
||||
if (!view?.renderer) return;
|
||||
for (const [name, value] of Object.entries(toRendererAttributes(current))) {
|
||||
view.renderer.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyles(current: ReaderSettings, dark: boolean) {
|
||||
// Absent on the fixed-layout renderer, which has no reflowable text.
|
||||
view?.renderer?.setStyles?.(buildReaderStyles(current, readReaderPalette(dark)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections render inside iframes, which swallow key events before they reach
|
||||
* the document — so arrow keys only worked with focus in the app chrome. The
|
||||
* old reader used epub.js's rendition.on('keydown'); foliate has no equivalent,
|
||||
* so bind on the section document each time one loads.
|
||||
*/
|
||||
function handleLoad(event: Event) {
|
||||
const { doc } = (event as CustomEvent<FoliateLoadDetail>).detail;
|
||||
doc.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a shortcut pressed inside the book on the host window.
|
||||
*
|
||||
* Key events do not cross document boundaries, so a shortcut pressed while
|
||||
* the book has focus never reaches the app's handlers — which are bound on
|
||||
* the window, as the sidebar's ctrl+B is — and the browser's own default runs
|
||||
* instead. That is why ctrl+B opened bookmarks in the book but toggled the
|
||||
* chapter sidebar everywhere else.
|
||||
*
|
||||
* The original is only cancelled if an app handler actually claimed the
|
||||
* replay, so combinations the app does not use — ctrl+C above all — keep
|
||||
* their normal browser behaviour.
|
||||
*/
|
||||
function forwardShortcut(event: KeyboardEvent) {
|
||||
const source = (event.target as Node | null)?.ownerDocument;
|
||||
// isTrusted rules out the replay itself, which would otherwise recurse.
|
||||
if (!event.isTrusted || !source || source === document) return;
|
||||
|
||||
const claimed = !window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
);
|
||||
|
||||
if (claimed) event.preventDefault();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
forwardShortcut(event);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
event.preventDefault();
|
||||
void goLeft();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
void goRight();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function goLeft() {
|
||||
return view?.goLeft();
|
||||
}
|
||||
|
||||
export function goRight() {
|
||||
return view?.goRight();
|
||||
}
|
||||
|
||||
/** Direction-aware, so the chevrons stay literal on right-to-left books. */
|
||||
export function goForward() {
|
||||
return rtl ? view?.goLeft() : view?.goRight();
|
||||
}
|
||||
|
||||
export function goBack() {
|
||||
return rtl ? view?.goRight() : view?.goLeft();
|
||||
}
|
||||
|
||||
export function goTo(target: FoliateTarget) {
|
||||
return view?.goTo(target);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
hostWidth = entry.contentRect.width;
|
||||
});
|
||||
if (container) resizeObserver.observe(container);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await loadFoliate();
|
||||
if (disposed || !container) return;
|
||||
|
||||
view = createFoliateView();
|
||||
|
||||
// Appended before open(): the paginator measures its host, and a
|
||||
// detached element has no size to measure.
|
||||
//
|
||||
// Deliberately outside Svelte's control. <foliate-view> is a custom
|
||||
// element that renders its own iframes; writing it in markup would
|
||||
// have SSR emit an unknown tag and leave Svelte trying to hydrate a
|
||||
// subtree the paginator owns.
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
||||
container.append(view);
|
||||
|
||||
await view.open(file);
|
||||
if (disposed) return;
|
||||
|
||||
rtl = view.book?.dir === 'rtl';
|
||||
|
||||
// Both before init(): the paginator stores the styles and re-applies
|
||||
// them on every section load, so the first page paints already themed
|
||||
// rather than flashing the book's own colours first.
|
||||
applyAttributes(settings);
|
||||
applyStyles(settings, mode.current === 'dark');
|
||||
|
||||
view.addEventListener('load', handleLoad);
|
||||
view.addEventListener('relocate', (event) => {
|
||||
onrelocate?.((event as CustomEvent<FoliateRelocateDetail>).detail);
|
||||
});
|
||||
|
||||
await view.init({ lastLocation: resolveStart(view) });
|
||||
if (disposed) return;
|
||||
|
||||
ready = true;
|
||||
onready?.({
|
||||
toc: view.book?.toc ?? [],
|
||||
isFixedLayout: view.isFixedLayout,
|
||||
rtl
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Could not open the book', error);
|
||||
onerror?.(error instanceof Error ? error.message : 'The file could not be opened.');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver.disconnect();
|
||||
for (const { doc } of view?.renderer?.getContents?.() ?? []) {
|
||||
doc.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
view?.close();
|
||||
view?.remove();
|
||||
view = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
// Re-applies on any settings change and on a light/dark flip.
|
||||
//
|
||||
// setStyles re-paginates internally and waits on document.fonts.ready, so no
|
||||
// manual resize is needed — unlike epub.js, which required a window resize
|
||||
// listener and rendition.resize().
|
||||
//
|
||||
// The styles are deferred by a frame on purpose: mode.current flips before
|
||||
// ModeWatcher writes .dark onto <html>, so reading the tokens in this tick
|
||||
// would style the book from the outgoing palette. That is the bug the old
|
||||
// reader shipped with — a white page against a dark UI.
|
||||
$effect(() => {
|
||||
const current = settings;
|
||||
const dark = mode.current === 'dark';
|
||||
if (!ready) return;
|
||||
|
||||
applyAttributes(current);
|
||||
|
||||
const frame = requestAnimationFrame(() => applyStyles(current, dark));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Not {onkeydown}: that shorthand resolves to the global window.onkeydown
|
||||
property, which TypeScript accepts and which is null at runtime, so the
|
||||
handler silently never ran and arrows only worked once the book had focus.
|
||||
-->
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative h-full w-full">
|
||||
<div bind:this={container} class="foliate-host"></div>
|
||||
|
||||
{#if ready && columns === 2}
|
||||
<!-- The gutter between the two pages of a spread. Sits in the column gap,
|
||||
so it never crosses text. -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-[6%] left-1/2 w-px -translate-x-1/2 bg-border"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* foliate-view extends bare HTMLElement, so it has no default display. */
|
||||
.foliate-host {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.foliate-host :global(foliate-view) {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import { RotateCcw } from '@lucide/svelte';
|
||||
|
||||
import type { ReaderFlow, ReaderSettings } from '$lib/schema/reader';
|
||||
import { READER_BOUNDS, READER_FONT_OPTIONS } from '$lib/reader/settings';
|
||||
import { getReaderSettingsState } from '$lib/state/reader-settings.svelte';
|
||||
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { Slider } from '$lib/components/ui/slider/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import * as Sheet from '$lib/components/ui/sheet/index.js';
|
||||
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
/**
|
||||
* Fixed-layout books render through foliate-fxl, which has no setStyles and
|
||||
* ignores the paginator's attributes — so the controls below would do
|
||||
* nothing. Hide them rather than leave dead sliders on screen.
|
||||
*/
|
||||
isFixedLayout = false
|
||||
}: {
|
||||
open?: boolean;
|
||||
isFixedLayout?: boolean;
|
||||
} = $props();
|
||||
|
||||
const state = getReaderSettingsState();
|
||||
|
||||
/** Slider binds an array; unwrap to the single value the setting holds. */
|
||||
function slide<K extends keyof ReaderSettings>(key: K) {
|
||||
return (value: number) => state.set(key, value as ReaderSettings[K]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-full gap-0 overflow-y-auto sm:max-w-sm">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title class="font-serif">Reading</Sheet.Title>
|
||||
<Sheet.Description>
|
||||
{isFixedLayout
|
||||
? 'This book has a fixed layout, so its typography is set by the publisher.'
|
||||
: 'Applies to every book you read.'}
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-6 px-4 pb-6">
|
||||
{#if !isFixedLayout}
|
||||
<section class="flex flex-col gap-4">
|
||||
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Typography
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="reader-font">Typeface</Label>
|
||||
<NativeSelect
|
||||
id="reader-font"
|
||||
class="w-full"
|
||||
value={state.settings.fontFamily}
|
||||
onchange={(event) => state.set('fontFamily', event.currentTarget.value)}
|
||||
>
|
||||
{#each READER_FONT_OPTIONS as font (font.value)}
|
||||
<option value={font.value}>{font.label}</option>
|
||||
{/each}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="reader-size">Size</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{state.settings.fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="reader-size"
|
||||
type="single"
|
||||
value={state.settings.fontSize}
|
||||
min={READER_BOUNDS.fontSize.min}
|
||||
max={READER_BOUNDS.fontSize.max}
|
||||
step={READER_BOUNDS.fontSize.step}
|
||||
onValueChange={slide('fontSize')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="reader-weight">Weight</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{state.settings.fontWeight}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="reader-weight"
|
||||
type="single"
|
||||
value={state.settings.fontWeight}
|
||||
min={READER_BOUNDS.fontWeight.min}
|
||||
max={READER_BOUNDS.fontWeight.max}
|
||||
step={READER_BOUNDS.fontWeight.step}
|
||||
onValueChange={slide('fontWeight')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="reader-leading">Line height</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{state.settings.lineHeight.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="reader-leading"
|
||||
type="single"
|
||||
value={state.settings.lineHeight}
|
||||
min={READER_BOUNDS.lineHeight.min}
|
||||
max={READER_BOUNDS.lineHeight.max}
|
||||
step={READER_BOUNDS.lineHeight.step}
|
||||
onValueChange={slide('lineHeight')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="reader-tracking">Letter spacing</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{state.settings.letterSpacing.toFixed(2)}em
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="reader-tracking"
|
||||
type="single"
|
||||
value={state.settings.letterSpacing}
|
||||
min={READER_BOUNDS.letterSpacing.min}
|
||||
max={READER_BOUNDS.letterSpacing.max}
|
||||
step={READER_BOUNDS.letterSpacing.step}
|
||||
onValueChange={slide('letterSpacing')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Label for="reader-justify" class="font-normal">Justify text</Label>
|
||||
<Switch
|
||||
id="reader-justify"
|
||||
checked={state.settings.justify}
|
||||
onCheckedChange={(checked) => state.set('justify', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Label for="reader-hyphenate" class="font-normal">Hyphenate</Label>
|
||||
<Switch
|
||||
id="reader-hyphenate"
|
||||
checked={state.settings.hyphenate}
|
||||
onCheckedChange={(checked) => state.set('hyphenate', checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
{/if}
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Layout
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label>Flow</Label>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={state.settings.flow}
|
||||
onValueChange={(value) => value && state.set('flow', value as ReaderFlow)}
|
||||
class="justify-start"
|
||||
>
|
||||
<ToggleGroup.Item value="paginated">Pages</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="scrolled">Scroll</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
{#if !isFixedLayout}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label>Columns</Label>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={String(state.settings.maxColumnCount)}
|
||||
onValueChange={(value) => value && state.set('maxColumnCount', Number(value))}
|
||||
class="justify-start"
|
||||
>
|
||||
<ToggleGroup.Item value="1">Single</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="2">Spread</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
A spread needs the window to be wide enough for two columns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="reader-width">Line width</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{state.settings.maxInlineSize}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="reader-width"
|
||||
type="single"
|
||||
value={state.settings.maxInlineSize}
|
||||
min={READER_BOUNDS.maxInlineSize.min}
|
||||
max={READER_BOUNDS.maxInlineSize.max}
|
||||
step={READER_BOUNDS.maxInlineSize.step}
|
||||
onValueChange={slide('maxInlineSize')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="reader-gap">Column gap</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{state.settings.gap}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="reader-gap"
|
||||
type="single"
|
||||
value={state.settings.gap}
|
||||
min={READER_BOUNDS.gap.min}
|
||||
max={READER_BOUNDS.gap.max}
|
||||
step={READER_BOUNDS.gap.step}
|
||||
onValueChange={slide('gap')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="reader-margin">Margins</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{state.settings.margin}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="reader-margin"
|
||||
type="single"
|
||||
value={state.settings.margin}
|
||||
min={READER_BOUNDS.margin.min}
|
||||
max={READER_BOUNDS.margin.max}
|
||||
step={READER_BOUNDS.margin.step}
|
||||
onValueChange={slide('margin')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button variant="outline" onclick={() => state.reset()} class="self-start">
|
||||
<RotateCcw class="size-4" />
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
|
||||
import type { FoliateTocItem } from '$foliate/view.js';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
||||
|
||||
let {
|
||||
toc,
|
||||
activeId = null,
|
||||
onnavigate
|
||||
}: {
|
||||
toc: FoliateTocItem[];
|
||||
/** From relocate.tocItem.id — foliate assigns these, books do not carry them. */
|
||||
activeId?: number | null;
|
||||
onnavigate: (href: string) => void;
|
||||
} = $props();
|
||||
|
||||
/** True when this item or any descendant is the current chapter. */
|
||||
function contains(item: FoliateTocItem, id: number | null): boolean {
|
||||
if (id === null) return false;
|
||||
if (item.id === id) return true;
|
||||
return item.subitems?.some((sub) => contains(sub, id)) ?? false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Root>
|
||||
<Sidebar.Header class="px-4 py-3">
|
||||
<p class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Chapters</p>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each toc as chapter (chapter.id)}
|
||||
{#if chapter.subitems?.length}
|
||||
<Collapsible.Root class="group/collapsible" open={contains(chapter, activeId)}>
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Sidebar.MenuItem class="min-w-0 flex-1">
|
||||
<Sidebar.MenuButton
|
||||
class="w-full"
|
||||
isActive={chapter.id === activeId}
|
||||
onclick={() => onnavigate(chapter.href)}
|
||||
>
|
||||
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Collapsible.Trigger class="flex-shrink-0 p-2">
|
||||
<ChevronDown
|
||||
class="size-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
{#each chapter.subitems ?? [] as subchapter (subchapter.id)}
|
||||
<Sidebar.MenuSubItem class="min-w-0">
|
||||
<Sidebar.MenuButton
|
||||
class="w-full"
|
||||
isActive={subchapter.id === activeId}
|
||||
onclick={() => onnavigate(subchapter.href)}
|
||||
>
|
||||
<span class="block truncate" title={subchapter.label}>
|
||||
{subchapter.label}
|
||||
</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else}
|
||||
<Sidebar.MenuItem class="min-w-0">
|
||||
<Sidebar.MenuButton
|
||||
class="w-full"
|
||||
isActive={chapter.id === activeId}
|
||||
onclick={() => onnavigate(chapter.href)}
|
||||
>
|
||||
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Content>
|
||||
</Sidebar.Root>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="avatar-badge"
|
||||
class={cn(
|
||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn(
|
||||
"rounded-full bg-muted text-muted-foreground flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="avatar-group-count"
|
||||
class={cn(
|
||||
"size-8 rounded-full bg-muted text-sm text-muted-foreground group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="avatar-group"
|
||||
class={cn(
|
||||
"cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("rounded-full aspect-square size-full object-cover", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
size = "default",
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps & {
|
||||
size?: "default" | "sm" | "lg";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,22 @@
|
||||
import Badge from "./avatar-badge.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
import GroupCount from "./avatar-group-count.svelte";
|
||||
import Group from "./avatar-group.svelte";
|
||||
import Image from "./avatar-image.svelte";
|
||||
import Root from "./avatar.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
Badge,
|
||||
Group,
|
||||
GroupCount,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
Badge as AvatarBadge,
|
||||
Group as AvatarGroup,
|
||||
GroupCount as AvatarGroupCount,
|
||||
};
|
||||
@@ -15,7 +15,8 @@
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
accent: 'bg-green-500 hover:bg-green-500/90 dark:text-primary dark:bg-green-500/90'
|
||||
accent:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import UploadIcon from '@lucide/svelte/icons/upload';
|
||||
import { displaySize } from '.';
|
||||
@@ -26,11 +27,14 @@
|
||||
...rest
|
||||
}: FileDropZoneProps = $props();
|
||||
|
||||
if (maxFiles !== undefined && fileCount === undefined) {
|
||||
console.warn(
|
||||
'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'
|
||||
);
|
||||
}
|
||||
// A one-off sanity check at init, not a reactive concern.
|
||||
untrack(() => {
|
||||
if (maxFiles !== undefined && fileCount === undefined) {
|
||||
console.warn(
|
||||
'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let uploading = $state(false);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
SIDEBAR_WIDTH_ICON
|
||||
} from './constants.js';
|
||||
import { setSidebar } from './context.svelte.js';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -36,7 +37,7 @@
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}_${contextKey}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
contextKey: contextKey
|
||||
contextKey: untrack(() => contextKey)
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { useSidebar } from './context.svelte.js';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +14,8 @@
|
||||
contextKey?: string;
|
||||
} = $props();
|
||||
|
||||
const sidebar = useSidebar(contextKey);
|
||||
// contextKey selects which sidebar this rail drives; fixed per instance.
|
||||
const sidebar = useSidebar(untrack(() => contextKey));
|
||||
</script>
|
||||
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from './slider.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { Slider as SliderPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
orientation = 'horizontal',
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
|
||||
The generated component styled the track with `data-horizontal:` /
|
||||
`data-vertical:` variants, which Tailwind compiles to `[data-horizontal]` —
|
||||
an attribute nothing sets, since the orientation is carried as
|
||||
`data-orientation="horizontal"`. The track therefore had no height and only
|
||||
the thumb was visible. These use the same `data-[orientation=…]` form as
|
||||
separator.svelte, which is the convention everywhere else in ui/.
|
||||
-->
|
||||
<SliderPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="slider"
|
||||
{orientation}
|
||||
class={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ thumbItems })}
|
||||
<span
|
||||
data-slot="slider-track"
|
||||
data-orientation={orientation}
|
||||
class={cn(
|
||||
'relative grow overflow-hidden rounded-full bg-muted',
|
||||
'data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full',
|
||||
'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
data-orientation={orientation}
|
||||
class={cn(
|
||||
'absolute bg-primary select-none',
|
||||
'data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{#each thumbItems as thumb (thumb.index)}
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb.index}
|
||||
class="relative block size-4 shrink-0 rounded-full border border-primary bg-background ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</SliderPrimitive.Root>
|
||||
@@ -206,6 +206,6 @@
|
||||
{placeholder}
|
||||
data-invalid={invalid}
|
||||
onkeydown={keydown}
|
||||
class="min-w-16 shrink grow basis-0 border-none bg-transparent px-2 outline-hidden placeholder:text-muted-foreground focus:outline-hidden disabled:cursor-not-allowed data-[invalid=true]:text-red-500 md:text-sm"
|
||||
class="min-w-16 shrink grow basis-0 border-none bg-transparent px-2 outline-hidden placeholder:text-muted-foreground focus:outline-hidden disabled:cursor-not-allowed data-[invalid=true]:text-destructive md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,16 @@
|
||||
...restProps
|
||||
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
|
||||
|
||||
// Getters rather than values: consumers read `ctx.variant` / `ctx.size` as
|
||||
// plain properties, so this stays source-compatible while making the props
|
||||
// actually reactive — passing them by value captured the initial ones.
|
||||
setToggleGroupCtx({
|
||||
variant,
|
||||
size
|
||||
get variant() {
|
||||
return variant;
|
||||
},
|
||||
get size() {
|
||||
return size;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -59,8 +59,7 @@
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) as shelf}
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={selectedBooks.every(
|
||||
(book) => book.lists.findIndex((sh) => sh.id === shelf.id) !== -1
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||
import { getFileType } from '$lib/utils';
|
||||
import {
|
||||
BookOpenCheck,
|
||||
BookOpenText,
|
||||
Download,
|
||||
EllipsisVertical,
|
||||
Pencil,
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
import type { Book, BookFile } from '$lib/schema';
|
||||
|
||||
let { book, class: className = '' }: { book: Book; class?: string } = $props();
|
||||
|
||||
const bookOps = getBookOperationsState();
|
||||
const libraryState = getLibraryState();
|
||||
const bookshelfState = getBookshelfState();
|
||||
|
||||
function openInReader(file: BookFile) {
|
||||
const type = getFileType(file.filename);
|
||||
if (type === 'EPUB' || type === 'PDF')
|
||||
window.open(`/book/${book.id}/read/${type.toLowerCase()}/${file.id}`, '_blank', 'noopener');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
The per-book overflow menu, shared so the grid, list and table cannot drift
|
||||
apart. data-row-control keeps a click here from toggling row selection.
|
||||
-->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
data-row-control
|
||||
aria-label="More actions for {book.title}"
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} {className}"
|
||||
>
|
||||
<EllipsisVertical class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="end" data-row-control>
|
||||
<DropdownMenu.Group>
|
||||
{#if book.files.length > 0}
|
||||
<DropdownMenu.Item onclick={() => openInReader(book.files[0])}>
|
||||
<BookOpenText class="size-4" />
|
||||
Read
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onclick={() => bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||
>
|
||||
<Download class="size-4" />
|
||||
Download
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
bookOps.bookToEdit = book;
|
||||
bookOps.editDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
Edit
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
onclick={async () => {
|
||||
if (!book.progress?.completed) await bookOps.markBooksAsComplete([book.id]);
|
||||
else await bookOps.markBooksAsIncomplete([book.id]);
|
||||
}}
|
||||
>
|
||||
<BookOpenCheck class="size-4" />
|
||||
{book.progress?.completed ? 'Mark as unfinished' : 'Mark as finished'}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="text-destructive"
|
||||
onclick={() => {
|
||||
bookOps.deleteDialogTitle = `Delete "${book.title}"?`;
|
||||
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||
await bookOps.deleteBooks([book.id], deleteFiles);
|
||||
libraryState.activeLibrary!.total!--;
|
||||
bookshelfState.deletedBooks([book]);
|
||||
};
|
||||
bookOps.deleteDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -12,7 +12,9 @@
|
||||
import FilterButton from './filter-button.svelte';
|
||||
import SortButton from './sort-button.svelte';
|
||||
import ViewToggle from './view-toggle.svelte';
|
||||
import PresetChips from './preset-chips.svelte';
|
||||
import BookTable from './book-table.svelte';
|
||||
import BookRows from './book-rows.svelte';
|
||||
import BatchOperationsToolbar from './batch-operations-toolbar.svelte';
|
||||
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
|
||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||
@@ -22,7 +24,6 @@
|
||||
const bookCollection = getBookCollectionState();
|
||||
const bookOps = getBookOperationsState();
|
||||
|
||||
let view = $state('grid');
|
||||
let sentinel = $state<HTMLElement>();
|
||||
let scrollContainer = $state<HTMLElement | null>(null);
|
||||
|
||||
@@ -66,7 +67,14 @@
|
||||
<div class="top-0 z-1 mb-4 flex h-12 w-full rounded-lg border bg-sidebar px-5">
|
||||
<div class="flex w-full items-center py-2">
|
||||
{#if !selectionState.selectionModeActive}
|
||||
<ViewToggle bind:view />
|
||||
<ViewToggle
|
||||
value={bookCollection.view}
|
||||
onValueChange={(next) => bookCollection.setView(next)}
|
||||
/>
|
||||
|
||||
<div class="mx-4 min-w-0 flex-1">
|
||||
<PresetChips />
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<SortButton />
|
||||
@@ -92,8 +100,10 @@
|
||||
</div>
|
||||
{:else if bookCollection.books.length > 0}
|
||||
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-11rem)] w-full px-5 pb-5">
|
||||
{#if view === 'grid'}
|
||||
{#if bookCollection.view === 'grid'}
|
||||
<BookGrid books={bookCollection.books} />
|
||||
{:else if bookCollection.view === 'list'}
|
||||
<BookRows books={bookCollection.books} />
|
||||
{:else}
|
||||
<BookTable books={bookCollection.books} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import type { Book } from '$lib/schema';
|
||||
import GeneratedCover from './generated-cover.svelte';
|
||||
|
||||
let {
|
||||
book,
|
||||
height = 220,
|
||||
class: className = ''
|
||||
}: { book: Book; height?: number; class?: string } = $props();
|
||||
|
||||
let failed = $state(false);
|
||||
|
||||
const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null);
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Covers arrive at whatever size the EPUB or PDF carried — the backend converts
|
||||
to WebP without resizing. This shows the whole cover at its own aspect ratio:
|
||||
the height is fixed so callers can rely on it for layout, and the width falls
|
||||
out of the image. Nothing is cropped and nothing is stretched.
|
||||
|
||||
The wrapper reserves a minimum width so the text beside it only settles by a
|
||||
few pixels once the image loads. Removing that last reflow needs the intrinsic
|
||||
dimensions stored at ingest.
|
||||
|
||||
That reservation is proportional to the height, not a fixed value: a 2:3 cover
|
||||
is 0.67x its height wide, so 0.6x reserves almost all of it without ever
|
||||
overshooting. A flat minimum would make the wrapper — and any link wrapping
|
||||
it — far wider than a small cover.
|
||||
-->
|
||||
<span
|
||||
class="inline-flex shrink-0 items-end {className}"
|
||||
style="height: {height}px; min-width: {Math.round(height * 0.6)}px;"
|
||||
>
|
||||
{#if src && !failed}
|
||||
<!--
|
||||
The rounded box lives on this wrapper, not the image, so anything
|
||||
overlaid on the cover — the progress bar — is clipped to the same
|
||||
silhouette instead of squaring off its corners. The shadow moves here
|
||||
too, since overflow-hidden would otherwise clip the image's own.
|
||||
-->
|
||||
<span class="relative h-full overflow-hidden rounded-sm shadow-lg">
|
||||
<img
|
||||
{src}
|
||||
alt="Cover of {book.title}"
|
||||
onerror={() => (failed = true)}
|
||||
class="h-full w-auto object-contain"
|
||||
style="max-width: {Math.round(height * 0.95)}px;"
|
||||
/>
|
||||
|
||||
{#if book.progress?.percentage}
|
||||
<span class="absolute inset-x-0 bottom-0 h-1 bg-black/30">
|
||||
<span
|
||||
class="block h-full {book.progress.completed ? 'bg-success' : 'bg-flag'}"
|
||||
style="width: {Math.min(100, Math.round(book.progress.percentage * 100))}%;"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- No cover on record, or the file is missing. Draw one. -->
|
||||
<span
|
||||
class="h-full overflow-hidden rounded-sm shadow-lg"
|
||||
style="width: {Math.round(height * 0.66)}px;"
|
||||
>
|
||||
<GeneratedCover {book} />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -31,8 +31,8 @@
|
||||
selectedState.toggleSelection(book);
|
||||
}}
|
||||
class="{selectedState.isSelected(book.id)
|
||||
? 'scale-110 text-yellow-300'
|
||||
: 'scale-75 text-white'} transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300"
|
||||
? 'scale-110 text-star'
|
||||
: 'scale-75 text-white'} transition-all hover:scale-110 hover:cursor-pointer hover:text-star"
|
||||
/>
|
||||
</div>
|
||||
{#if selectedState.isSelected(book.id)}
|
||||
@@ -42,7 +42,7 @@
|
||||
: 'opacity-0'} transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<Circle
|
||||
class="yellow-300 scale-50 fill-yellow-300 text-yellow-300 transition-all hover:scale-75 hover:cursor-pointer hover:text-yellow-300"
|
||||
class="scale-50 fill-star text-star transition-all hover:scale-75 hover:cursor-pointer hover:text-star"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -54,7 +54,7 @@
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<EllipsisVertical
|
||||
class="scale-75 text-white transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300"
|
||||
class="scale-75 text-white transition-all hover:scale-110 hover:cursor-pointer hover:text-star"
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
@@ -89,7 +89,7 @@
|
||||
class="absolute right-2 bottom-20 z-50 inline-flex items-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<Pencil
|
||||
class="scale-75 text-white transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300"
|
||||
class="scale-75 text-white transition-all hover:scale-110 hover:cursor-pointer hover:text-star"
|
||||
onclick={() => {
|
||||
bookOps.bookToEdit = book;
|
||||
bookOps.editDialogOpen = true;
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
// Start observing
|
||||
resizeObserver.observe(scrollContainer);
|
||||
|
||||
// Initial check with delay
|
||||
setTimeout(updateScrollState, 0);
|
||||
// Measure synchronously. Reading scrollWidth/clientWidth forces layout, so
|
||||
// the numbers are already accurate here — deferring to a macrotask just
|
||||
// guaranteed one painted frame with the arrows in the wrong state.
|
||||
updateScrollState();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
@@ -33,12 +35,14 @@
|
||||
});
|
||||
|
||||
const scrollLeft = () => {
|
||||
if (!scrollContainer) return;
|
||||
scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' });
|
||||
// Update state after scroll
|
||||
setTimeout(updateScrollState, 100);
|
||||
};
|
||||
|
||||
const scrollRight = () => {
|
||||
if (!scrollContainer) return;
|
||||
scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' });
|
||||
// Update state after scroll
|
||||
setTimeout(updateScrollState, 100);
|
||||
@@ -61,29 +65,32 @@
|
||||
{#if books.length > 0}
|
||||
<div class="flex w-full flex-col">
|
||||
<div class="flex items-center">
|
||||
<h1 class="ml-4 text-xl font-semibold">{title}</h1>
|
||||
{#if needsScroll}
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canScrollLeft}
|
||||
onclick={scrollLeft}
|
||||
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Left"
|
||||
>
|
||||
<ChevronLeft size="20" strokeWidth="3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canScrollRight}
|
||||
onclick={scrollRight}
|
||||
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Right"
|
||||
>
|
||||
<ChevronRight size="20" strokeWidth="3" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="ml-4 font-serif text-xl font-semibold">{title}</h1>
|
||||
<!--
|
||||
Always mounted, only hidden. `needsScroll` is measured after mount,
|
||||
so mounting on it made the arrows pop in a frame late and shift the
|
||||
header; `invisible` keeps the space reserved from the first paint.
|
||||
-->
|
||||
<div class="ml-auto {needsScroll ? '' : 'invisible'}" aria-hidden={!needsScroll}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canScrollLeft}
|
||||
onclick={scrollLeft}
|
||||
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Left"
|
||||
>
|
||||
<ChevronLeft size="20" strokeWidth="3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canScrollRight}
|
||||
onclick={scrollRight}
|
||||
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Right"
|
||||
>
|
||||
<ChevronRight size="20" strokeWidth="3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import BookCover from './book-cover.svelte';
|
||||
import BookActionsMenu from './book-actions-menu.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import { Badge, badgeVariants } from '$lib/components/ui/badge/index';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { getFileType } from '$lib/utils';
|
||||
import { BookOpenText, Check, Download } from '@lucide/svelte';
|
||||
import type { Book, BookFile } from '$lib/schema';
|
||||
|
||||
let { books }: { books: Book[] } = $props();
|
||||
|
||||
const selectionState = getBookSelectionState();
|
||||
const bookOps = getBookOperationsState();
|
||||
const libraryState = getLibraryState();
|
||||
|
||||
/** At most three, so cards with a heavy tag list stay the same height as the rest. */
|
||||
const TAG_LIMIT = 3;
|
||||
|
||||
function authors(book: Book) {
|
||||
return book.authors.map((a) => a.name).join(', ');
|
||||
}
|
||||
|
||||
function year(book: Book) {
|
||||
return book.published_date ? new Date(book.published_date).getFullYear() : null;
|
||||
}
|
||||
|
||||
function formats(book: Book) {
|
||||
return [...new Set(book.files.map((f) => getFileType(f.filename)))].join(' + ');
|
||||
}
|
||||
|
||||
/**
|
||||
* BookProgressRead does not expose a timestamp yet, though BookProgress
|
||||
* extends BigIntAuditBase so updated_at exists in the database. Reading it
|
||||
* defensively means the date appears on its own once the schema catches up.
|
||||
* See TODO.md.
|
||||
*/
|
||||
function finishedOn(book: Book) {
|
||||
const value = (book.progress as { updated_at?: string } | null | undefined)?.updated_at;
|
||||
return value
|
||||
? new Date(value).toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line where the progress bar used to be — the cover already draws the
|
||||
* bar, so this says something the bar cannot. Unread books report their
|
||||
* length rather than announcing that they are unread.
|
||||
*/
|
||||
function readState(book: Book): { text: string; tone: 'done' | 'reading' | 'idle' } {
|
||||
const progress = book.progress;
|
||||
const pages = book.pages ?? null;
|
||||
|
||||
if (progress?.completed) {
|
||||
const on = finishedOn(book);
|
||||
return { text: on ? `Finished ${on}` : 'Finished', tone: 'done' };
|
||||
}
|
||||
|
||||
if (progress?.percentage) {
|
||||
// pdf_page is a real page number when the reader recorded one;
|
||||
// otherwise infer it from the percentage.
|
||||
const current = progress.pdf_page ?? (pages ? Math.round(progress.percentage * pages) : null);
|
||||
|
||||
return {
|
||||
text:
|
||||
pages && current
|
||||
? `Reading · ${current} of ${pages}`
|
||||
: `Reading · ${Math.round(progress.percentage * 100)}%`,
|
||||
tone: 'reading'
|
||||
};
|
||||
}
|
||||
|
||||
return { text: pages ? `${pages} pages` : '', tone: 'idle' };
|
||||
}
|
||||
|
||||
function openInReader(book: Book, file: BookFile) {
|
||||
const type = getFileType(file.filename);
|
||||
if (type === 'EPUB' || type === 'PDF')
|
||||
window.open(`/book/${book.id}/read/${type.toLowerCase()}/${file.id}`, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a selection exists, selecting is the primary interaction — so the
|
||||
* whole card toggles, not just the checkbox. preventDefault also stops the
|
||||
* links inside from navigating, since the click bubbles through them here.
|
||||
*/
|
||||
function handleCardClick(event: MouseEvent, book: Book) {
|
||||
if (!selectionState.selectionModeActive) return;
|
||||
if ((event.target as HTMLElement).closest('[data-row-control]')) return;
|
||||
|
||||
event.preventDefault();
|
||||
selectionState.toggleSelection(book);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Detail cards: a browsable cover beside real metadata, sitting between the
|
||||
grid (covers, no data) and the table (data, no covers). The column count
|
||||
comes from the container rather than a breakpoint, so it reflows from one to
|
||||
three as the sidebar opens and closes.
|
||||
-->
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(330px,1fr))] gap-3.5 p-1">
|
||||
{#each books as book (book.id)}
|
||||
{@const selected = selectionState.isSelected(book.id)}
|
||||
{@const state = readState(book)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={(event) => handleCardClick(event, book)}
|
||||
class="group flex gap-3.5 rounded-lg border bg-card p-3 transition-colors {selected
|
||||
? 'border-primary bg-accent'
|
||||
: 'border-border/60 hover:border-border'} {selectionState.selectionModeActive
|
||||
? 'cursor-pointer'
|
||||
: ''}"
|
||||
>
|
||||
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })} class="shrink-0">
|
||||
<BookCover {book} height={110} />
|
||||
</a>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="line-clamp-2 font-serif text-sm leading-snug hover:underline"
|
||||
>
|
||||
{book.title}
|
||||
</a>
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground">{authors(book)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Hidden until hover, pinned once selected — as in the grid -->
|
||||
<button
|
||||
type="button"
|
||||
data-row-control
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
aria-label="Select {book.title}"
|
||||
onclick={() => selectionState.toggleSelection(book)}
|
||||
class="grid size-4 shrink-0 place-items-center rounded-sm border transition-opacity {selected
|
||||
? 'border-primary bg-primary text-primary-foreground opacity-100'
|
||||
: 'border-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100'}"
|
||||
>
|
||||
{#if selected}
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1">
|
||||
{#if year(book)}
|
||||
<Badge variant="secondary" class="font-mono text-[10px] tabular-nums">
|
||||
{year(book)}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if book.files.length > 0}
|
||||
<Badge variant="outline" class="font-mono text-[10px]">{formats(book)}</Badge>
|
||||
{/if}
|
||||
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
|
||||
<a
|
||||
data-row-control
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||
})}?tags={tag.id}"
|
||||
class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a
|
||||
>
|
||||
{/each}
|
||||
{#if book.tags.length > TAG_LIMIT}
|
||||
<span class="font-mono text-[10px] text-muted-foreground">
|
||||
+{book.tags.length - TAG_LIMIT}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex items-center gap-2 pt-2.5">
|
||||
<!-- Actions on the left, reading state anchored bottom-right -->
|
||||
<div
|
||||
data-row-control
|
||||
class="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{#if book.files.length > 0}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-7"
|
||||
onclick={() => openInReader(book, book.files[0])}
|
||||
>
|
||||
<BookOpenText class="size-4" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom"><p>Read</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-7"
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||
>
|
||||
<Download class="size-4" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom"><p>Download</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
|
||||
<BookActionsMenu {book} class="size-7" />
|
||||
</div>
|
||||
|
||||
{#if state.text}
|
||||
<span
|
||||
class="ml-auto truncate font-mono text-xs tabular-nums {state.tone === 'done'
|
||||
? 'font-semibold text-success'
|
||||
: state.tone === 'reading'
|
||||
? 'font-semibold text-flag'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{state.text}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,66 +1,304 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Table from '$lib/components/ui/table/index';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index';
|
||||
import BookImage from './book-image.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge/index';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import BookCover from './book-cover.svelte';
|
||||
import BookActionsMenu from './book-actions-menu.svelte';
|
||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { formatFileSize, getFileType } from '$lib/utils';
|
||||
import { ArrowDown, ArrowUp, Columns3 } from '@lucide/svelte';
|
||||
import type { Book } from '$lib/schema';
|
||||
|
||||
let { books } = $props();
|
||||
let { books }: { books: Book[] } = $props();
|
||||
|
||||
const selectedState = getBookSelectionState();
|
||||
const selectionState = getBookSelectionState();
|
||||
const bookCollection = getBookCollectionState();
|
||||
const libraryState = getLibraryState();
|
||||
|
||||
/**
|
||||
* Column definitions.
|
||||
*
|
||||
* `sort` names the backend orderBy field. Only the fields the API can
|
||||
* actually order by carry one — the rest are not clickable, rather than
|
||||
* offering a header that silently does nothing.
|
||||
*/
|
||||
type Column = {
|
||||
key: string;
|
||||
label: string;
|
||||
sort?: string;
|
||||
numeric?: boolean;
|
||||
on: boolean;
|
||||
fixed?: boolean;
|
||||
};
|
||||
|
||||
let columns = $state<Column[]>([
|
||||
{ key: 'title', label: 'Title', sort: 'title', on: true, fixed: true },
|
||||
{ key: 'authors', label: 'Authors', on: true },
|
||||
{ key: 'series', label: 'Series', on: false },
|
||||
{ key: 'publisher', label: 'Publisher', on: false },
|
||||
{ key: 'published', label: 'Year', sort: 'published_date', numeric: true, on: true },
|
||||
{ key: 'pages', label: 'Pages', sort: 'pages', numeric: true, on: true },
|
||||
{ key: 'format', label: 'Format', on: true },
|
||||
{ key: 'size', label: 'Size', numeric: true, on: true },
|
||||
{ key: 'added', label: 'Added', sort: 'created_at', on: false },
|
||||
{ key: 'progress', label: 'Progress', sort: 'last_accessed', on: true },
|
||||
{ key: 'missing', label: 'Missing', on: true }
|
||||
]);
|
||||
|
||||
const visible = $derived(columns.filter((c) => c.on));
|
||||
|
||||
const allSelected = $derived(
|
||||
books.length > 0 && books.every((b) => selectionState.isSelected(b.id))
|
||||
);
|
||||
|
||||
/** What a record is lacking — the reason this view exists. */
|
||||
function missing(book: Book) {
|
||||
const gaps: string[] = [];
|
||||
if (!book.cover_image) gaps.push('cover');
|
||||
if (!book.description) gaps.push('description');
|
||||
if (!book.identifiers || Object.keys(book.identifiers).length === 0) gaps.push('isbn');
|
||||
if (!book.publisher) gaps.push('publisher');
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function totalSize(book: Book) {
|
||||
return book.files.reduce((n, f) => n + f.size, 0);
|
||||
}
|
||||
|
||||
function formats(book: Book) {
|
||||
return [...new Set(book.files.map((f) => getFileType(f.filename)))].join(' + ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a selection exists, selecting is the primary interaction — the whole
|
||||
* row toggles. preventDefault also stops the title and author links inside
|
||||
* the row from navigating, since the click bubbles through them to here.
|
||||
*/
|
||||
function handleRowClick(event: MouseEvent, book: Book) {
|
||||
if (!selectionState.selectionModeActive) return;
|
||||
if ((event.target as HTMLElement).closest('[data-row-control]')) return;
|
||||
|
||||
event.preventDefault();
|
||||
selectionState.toggleSelection(book);
|
||||
}
|
||||
|
||||
/**
|
||||
* BookRead does not expose created_at yet, though the column exists in the
|
||||
* database and the API can already order by it — so the header sorts today
|
||||
* and the cell fills in by itself once the schema catches up. See TODO.md.
|
||||
*/
|
||||
function addedOn(book: Book) {
|
||||
const value = (book as Book & { created_at?: string }).created_at;
|
||||
return value ? new Date(value).toLocaleDateString() : '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head></Table.Head>
|
||||
<Table.Head class="max-w-24 min-w-16">Cover</Table.Head>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head class="max-w-24 min-w-16">Authors</Table.Head>
|
||||
<Table.Head>Publisher</Table.Head>
|
||||
<Table.Head>Published Date</Table.Head>
|
||||
<Table.Head>Pages</Table.Head>
|
||||
<Table.Head>Tags</Table.Head>
|
||||
<Table.Head>Identifiers</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each books as book (book.id)}
|
||||
<div class="flex flex-col gap-3">
|
||||
<!--
|
||||
Column picker. Sorting lives on the headers, so this is all the toolbar needs.
|
||||
|
||||
sticky left-0 + w-fit keeps it against the viewport's left edge while the
|
||||
table scrolls sideways. Without it the toolbar is as wide as the table — it
|
||||
is a sibling inside the ScrollArea's fit-content wrapper — so a right
|
||||
aligned button ends up off-screen once enough columns are on. This works
|
||||
where the sticky columns could not, because the toolbar sits outside the
|
||||
table's own overflow-x-auto container.
|
||||
-->
|
||||
<div class="sticky left-0 flex w-fit items-center gap-3">
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{books.length} shown
|
||||
</span>
|
||||
<div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={buttonVariants({ variant: 'outline', size: 'sm' })}>
|
||||
<Columns3 class="size-4" />
|
||||
Columns
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-48">
|
||||
<!-- GroupHeading reads the group context, so it has to sit inside a Group -->
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Show columns</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each columns as column (column.key)}
|
||||
<!-- closeOnSelect={false} so several columns can be toggled in one pass -->
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={column.on}
|
||||
disabled={column.fixed}
|
||||
closeOnSelect={false}
|
||||
onCheckedChange={(value) => {
|
||||
if (!column.fixed) column.on = value;
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The table is w-full by default, so with many columns on it compresses every
|
||||
cell rather than growing. w-max lets it take the width its content needs and
|
||||
scroll horizontally in the browser's ScrollArea; min-w-full keeps it filling
|
||||
the container when only a few columns are on.
|
||||
-->
|
||||
<Table.Root class="w-max min-w-full">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Cell
|
||||
><Checkbox
|
||||
checked={selectedState.isSelected(book.id)}
|
||||
onCheckedChange={() => selectedState.toggleSelection(book)}
|
||||
/></Table.Cell
|
||||
>
|
||||
<Table.Cell>
|
||||
<BookImage src="/api/{book.cover_image}" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{book.title}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{book.publisher}</Table.Cell>
|
||||
<Table.Cell>{book.published_date}</Table.Cell>
|
||||
<Table.Cell>{book.pages}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each book.tags as tag}
|
||||
{tag.name}
|
||||
{/each}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each Object.entries(book.identifiers) as [name, value] (value)}
|
||||
<span class="cs-list">{name}</span>  
|
||||
{/each}
|
||||
</Table.Cell>
|
||||
<Table.Head class="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
aria-label="Select all loaded books"
|
||||
onCheckedChange={() => {
|
||||
if (allSelected) selectionState.deselectAll();
|
||||
else selectionState.selectAll(books);
|
||||
}}
|
||||
/>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-12"></Table.Head>
|
||||
|
||||
{#each visible as column (column.key)}
|
||||
<Table.Head class={column.numeric ? 'text-right' : ''}>
|
||||
{#if column.sort}
|
||||
<!-- Sorting is server-side: the header drives the same orderBy /
|
||||
sortOrder state the sort menu uses, so it orders the whole
|
||||
library rather than the pages loaded so far. -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 hover:text-foreground {bookCollection.orderBy ===
|
||||
column.sort
|
||||
? 'font-semibold text-foreground'
|
||||
: ''}"
|
||||
onclick={() => bookCollection.updateSort(column.sort!)}
|
||||
>
|
||||
{column.label}
|
||||
{#if bookCollection.orderBy === column.sort}
|
||||
{#if bookCollection.sortOrder === 'asc'}
|
||||
<ArrowUp class="size-3" />
|
||||
{:else}
|
||||
<ArrowDown class="size-3" />
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{column.label}
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{#each books as book (book.id)}
|
||||
<Table.Row
|
||||
data-state={selectionState.isSelected(book.id) ? 'selected' : undefined}
|
||||
onclick={(event: MouseEvent) => handleRowClick(event, book)}
|
||||
class={selectionState.selectionModeActive ? 'cursor-pointer' : ''}
|
||||
>
|
||||
<Table.Cell data-row-control>
|
||||
<Checkbox
|
||||
checked={selectionState.isSelected(book.id)}
|
||||
aria-label="Select {book.title}"
|
||||
onCheckedChange={() => selectionState.toggleSelection(book)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
><BookCover {book} height={36} /></a
|
||||
>
|
||||
</Table.Cell>
|
||||
|
||||
{#each visible as column (column.key)}
|
||||
{#if column.key === 'title'}
|
||||
<Table.Cell class="max-w-[280px] truncate font-serif">
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="hover:underline">{book.title}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'authors'}
|
||||
<Table.Cell class="max-w-[180px] truncate">
|
||||
{#each book.authors as author (author.id)}
|
||||
<a
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||
})}?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'series'}
|
||||
<Table.Cell class="text-muted-foreground">
|
||||
{book.series ? book.series.title : '—'}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'publisher'}
|
||||
<Table.Cell class="max-w-[160px] truncate text-muted-foreground">
|
||||
{book.publisher ? book.publisher.name : '—'}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'published'}
|
||||
<Table.Cell class="text-right font-mono text-xs tabular-nums">
|
||||
{book.published_date ? new Date(book.published_date).getFullYear() : '—'}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'pages'}
|
||||
<Table.Cell class="text-right font-mono text-xs tabular-nums">
|
||||
{book.pages ?? '—'}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'format'}
|
||||
<Table.Cell><span class="font-mono text-xs">{formats(book)}</span></Table.Cell>
|
||||
{:else if column.key === 'size'}
|
||||
<Table.Cell class="text-right font-mono text-xs tabular-nums">
|
||||
{formatFileSize(totalSize(book))}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'added'}
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{addedOn(book)}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'progress'}
|
||||
<Table.Cell>
|
||||
{#if book.progress?.completed}
|
||||
<span class="font-mono text-xs font-semibold text-success">Finished</span>
|
||||
{:else if book.progress?.percentage}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="h-1 w-16 overflow-hidden rounded-full bg-muted-foreground/25">
|
||||
<span
|
||||
class="block h-full bg-flag"
|
||||
style="width: {Math.round(book.progress.percentage * 100)}%;"
|
||||
></span>
|
||||
</span>
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{Math.round(book.progress.percentage * 100)}%
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-mono text-xs text-muted-foreground">Unread</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'missing'}
|
||||
<Table.Cell>
|
||||
{#each missing(book) as gap (gap)}
|
||||
<Badge variant="outline" class="mr-1 border-flag font-mono text-[10px] text-flag">
|
||||
{gap}
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="font-mono text-xs text-success">complete</span>
|
||||
{/each}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Table.Cell class="text-right">
|
||||
<BookActionsMenu {book} class="size-8" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import BookImage from './book-image.svelte';
|
||||
import GeneratedCover from './generated-cover.svelte';
|
||||
import { Progress } from '$lib/components/ui/progress/index';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||
import type { Book } from '$lib/schema';
|
||||
|
||||
let { book, class: className = '', ...rest }: { book: Book, class?: string} = $props();
|
||||
let { book, class: className = '', ...rest }: { book: Book; class?: string } = $props();
|
||||
|
||||
const selectionState = getBookSelectionState();
|
||||
const libraryState = getLibraryState();
|
||||
@@ -24,32 +26,53 @@
|
||||
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
|
||||
<!-- Book Cover -->
|
||||
<a
|
||||
href="/book/{book.id}"
|
||||
class="group relative aspect-9/12 w-full overflow-hidden rounded shadow-lg drop-shadow-lg transition-all duration-200 {selected
|
||||
? 'ring-2 ring-yellow-300'
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="group relative aspect-9/12 w-full overflow-hidden rounded-sm shadow-lg drop-shadow-lg transition-all duration-200 {selected
|
||||
? 'ring-2 ring-star'
|
||||
: ''}"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="h-full w-full rounded object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
|
||||
darkened
|
||||
? 'brightness-50'
|
||||
: ''}"
|
||||
/>
|
||||
<!--
|
||||
Checked rather than left to the image's onerror: with no cover_image the
|
||||
src became "/api/undefined", 404'd, and fell back to the generic
|
||||
default_cover.jpg — which is what made every coverless book look alike in
|
||||
the grid.
|
||||
-->
|
||||
{#if book.cover_image}
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
|
||||
darkened
|
||||
? 'brightness-50'
|
||||
: ''}"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-full w-full transition-all duration-200 group-hover:brightness-50 {selected ||
|
||||
darkened
|
||||
? 'brightness-50'
|
||||
: ''}"
|
||||
>
|
||||
<GeneratedCover {book} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Inside the anchor, which already clips to rounded-sm, so the bar
|
||||
follows the cover's corners instead of floating below it -->
|
||||
{#if book.progress?.percentage && !selected && !darkened}
|
||||
<span class="absolute inset-x-0 bottom-0 h-1 bg-black/30">
|
||||
<span
|
||||
class="block h-full {book.progress.completed ? 'bg-success' : 'bg-flag'}"
|
||||
style="width: {Math.min(100, Math.round(book.progress.percentage * 100))}%;"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{#if book.progress?.percentage && !selected && !darkened}
|
||||
<Progress
|
||||
value={book.progress.percentage}
|
||||
max={1}
|
||||
class="mt-[-8px] h-1 rounded {book.progress.completed
|
||||
? '[&>div]:bg-green-600'
|
||||
: '[&>div]:bg-yellow-500'}"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Book Title -->
|
||||
<a href="/book/{book.id}" class="text-base-content mt-1 line-clamp-2 w-full text-sm hover:underline"
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
|
||||
>{book.title}</a
|
||||
>
|
||||
|
||||
@@ -57,7 +80,9 @@
|
||||
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||
})}?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
>
|
||||
<Funnel />
|
||||
{#if bookCollection.hasActiveFilters}
|
||||
<div class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
|
||||
<div class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"></div>
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<Collapsible.Trigger {...props}>
|
||||
{filter.name}
|
||||
{#if bookCollection.filters[filter.value].length !== 0}
|
||||
<div class="ml-2 h-[6px] w-[6px] rounded-full bg-yellow-500"></div>
|
||||
<div class="ml-2 h-[6px] w-[6px] rounded-full bg-flag"></div>
|
||||
{/if}
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import type { Book } from '$lib/schema';
|
||||
|
||||
let { book, class: className = '' }: { book: Book; class?: string } = $props();
|
||||
|
||||
/**
|
||||
* Bookcloth tones rather than a point on the hue wheel.
|
||||
*
|
||||
* The old placeholder derived a hue from the title with `hash % 360`, which
|
||||
* eventually lands on acid green. Hashing into a fixed list keeps every
|
||||
* generated cover looking like it came from the same library.
|
||||
*/
|
||||
const CLOTH = [
|
||||
'#1f5f5b', // teal, the app's own accent
|
||||
'#7a2e2a', // oxblood
|
||||
'#2f4858', // slate
|
||||
'#3c5148', // forest
|
||||
'#6b4a6e', // plum
|
||||
'#8a6a1f', // brass
|
||||
'#2b3038', // graphite
|
||||
'#5a4632' // tobacco
|
||||
];
|
||||
|
||||
/** Weaves, as CSS gradients — an SVG <pattern> would need a unique id per cover. */
|
||||
const WEAVE = [
|
||||
// twill
|
||||
`repeating-linear-gradient(45deg, transparent 0 4px, currentColor 4px 5px)`,
|
||||
// horizontal ribbing
|
||||
`repeating-linear-gradient(0deg, transparent 0 5px, currentColor 5px 6px)`,
|
||||
// crosshatch
|
||||
`repeating-linear-gradient(45deg, transparent 0 6px, currentColor 6px 7px),
|
||||
repeating-linear-gradient(-45deg, transparent 0 6px, currentColor 6px 7px)`,
|
||||
// vertical laid lines
|
||||
`repeating-linear-gradient(90deg, transparent 0 5px, currentColor 5px 6px)`
|
||||
];
|
||||
|
||||
/** Stable per title, so a book always looks the same. */
|
||||
function hash(value: string) {
|
||||
let out = 7;
|
||||
for (const char of value) out = (out * 31 + char.charCodeAt(0)) % 100_000;
|
||||
return out;
|
||||
}
|
||||
|
||||
const seed = $derived(hash(book.title || 'Untitled'));
|
||||
const cloth = $derived(CLOTH[seed % CLOTH.length]);
|
||||
const weave = $derived(WEAVE[(seed >> 3) % WEAVE.length]);
|
||||
|
||||
const authors = $derived(book.authors?.map((author) => author.name).join(', ') ?? '');
|
||||
|
||||
/**
|
||||
* Stepped by length rather than scaled continuously: a long title shrunk to
|
||||
* fit would end up smaller than the author line, which reads as a mistake.
|
||||
* Units are cqw so the whole thing works at any size the caller asks for.
|
||||
*/
|
||||
const titleSize = $derived.by(() => {
|
||||
const length = (book.title ?? '').length;
|
||||
if (length <= 14) return 17;
|
||||
if (length <= 28) return 13;
|
||||
if (length <= 46) return 10;
|
||||
return 8.5;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Drawn, not stored. It costs nothing, follows the title when it is edited, and
|
||||
is replaced the moment a real cover is uploaded. Colours are fixed rather than
|
||||
themed: this is an object on a shelf beside real artwork, which does not
|
||||
change with the theme either.
|
||||
-->
|
||||
<div
|
||||
class="generated-cover {className}"
|
||||
style="--cloth: {cloth};"
|
||||
role="img"
|
||||
aria-label="Generated cover for {book.title}"
|
||||
>
|
||||
<div class="weave" style="background-image: {weave};"></div>
|
||||
|
||||
<div class="type">
|
||||
<span class="title" style="font-size: {titleSize}cqw;">{book.title}</span>
|
||||
{#if authors}
|
||||
<span class="author">{authors}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.generated-cover {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--cloth);
|
||||
color: #f4f1ec;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* currentColor drives the gradient, so one rule covers every weave. */
|
||||
.weave {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #ffffff;
|
||||
opacity: 0.14;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* A little depth so it does not read as a flat swatch next to a photograph. */
|
||||
.generated-cover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(150deg, rgb(255 255 255 / 0.1), rgb(0 0 0 / 0.28));
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6cqw;
|
||||
padding: 10cqw 9cqw;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--app-font-serif, Georgia, serif);
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
/* Clip rather than spill: an overflowing title looks broken, a clipped one
|
||||
just looks like a long title. */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 6;
|
||||
line-clamp: 6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-family: var(--app-font-mono, ui-monospace, monospace);
|
||||
font-size: 4.4cqw;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Below about a centimetre the author is unreadable, and the padding is
|
||||
eating the title. Show the title alone. */
|
||||
@container (max-width: 72px) {
|
||||
.author {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.type {
|
||||
padding: 8cqw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { BOOK_PRESETS } from '$lib/presets';
|
||||
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
|
||||
|
||||
const bookCollection = getBookCollectionState();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Built-in views, not shelves: a fixed set every library has, so nothing here is
|
||||
named, owned or persisted. The same definitions drive the home page's shelves,
|
||||
so "Recently added" means the same thing in both places.
|
||||
-->
|
||||
<div class="flex items-center gap-1.5 overflow-x-auto" aria-label="Preset views">
|
||||
{#each BOOK_PRESETS as preset (preset.id)}
|
||||
{@const active = bookCollection.isPresetActive(preset)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))}
|
||||
class="shrink-0 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors {active
|
||||
? 'border-primary bg-primary text-primary-foreground font-medium'
|
||||
: 'border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@
|
||||
>
|
||||
{#if bookCollection.hasActiveSort}
|
||||
<div
|
||||
class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-yellow-500"
|
||||
class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"
|
||||
></div>
|
||||
{/if}
|
||||
<ArrowUpDown />
|
||||
|
||||
@@ -1,40 +1,54 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import * as ToggleGroup from '$lib/components/ui/toggle-group/index';
|
||||
import { BOOK_VIEWS, type BookView } from '$lib/state/bookCollection.svelte';
|
||||
|
||||
import { List, LayoutGrid } from '@lucide/svelte';
|
||||
import { List, LayoutGrid, Table } from '@lucide/svelte';
|
||||
|
||||
let { view = $bindable(), class: className = '' } = $props();
|
||||
/**
|
||||
* Controlled rather than bound: the parent writes the URL when the view
|
||||
* changes, and a callback keeps that write next to the intent instead of
|
||||
* needing an effect that also fires on the initial assignment.
|
||||
*/
|
||||
let {
|
||||
value,
|
||||
onValueChange,
|
||||
class: className = ''
|
||||
}: {
|
||||
value: BookView;
|
||||
onValueChange: (view: BookView) => void;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const ICONS = { grid: LayoutGrid, list: List, table: Table };
|
||||
const LABELS = { grid: 'Grid view', list: 'List view', table: 'Table view' };
|
||||
</script>
|
||||
|
||||
<ToggleGroup.Root type="single" bind:value={view} class={className}>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<ToggleGroup.Item value="grid" aria-label="Toggle grid view" {...props}>
|
||||
<LayoutGrid class="size-4" />
|
||||
</ToggleGroup.Item>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Grid view</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<ToggleGroup.Item value="list" aria-label="Toggle list view" {...props}>
|
||||
<List class="size-4" />
|
||||
</ToggleGroup.Item>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>List view</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
{value}
|
||||
onValueChange={(next) => {
|
||||
// The group emits '' when the active item is pressed again; keep the
|
||||
// current view rather than leaving the browser with nothing to render.
|
||||
if (next) onValueChange(next as BookView);
|
||||
}}
|
||||
class={className}
|
||||
>
|
||||
{#each BOOK_VIEWS as view (view)}
|
||||
{@const Icon = ICONS[view]}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<ToggleGroup.Item value={view} aria-label={LABELS[view]} {...props}>
|
||||
<Icon class="size-4" />
|
||||
</ToggleGroup.Item>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>{LABELS[view]}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/each}
|
||||
</ToggleGroup.Root>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Built-in views over a library.
|
||||
*
|
||||
* These are navigation, not content — a fixed set of lenses every library has,
|
||||
* as distinct from shelves, which are named collections a user owns. Nothing
|
||||
* here is persisted or user-editable.
|
||||
*
|
||||
* Defined once and consumed twice: the home route renders them as shelves, and
|
||||
* the library view renders them as chips. Previously each home shelf was a
|
||||
* hand-written query string in the loader, which is how `create_at` (missing a
|
||||
* `d`) went unnoticed — that sort silently did nothing.
|
||||
*/
|
||||
|
||||
export interface BookPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Shown as the shelf heading on the home page. */
|
||||
heading: string;
|
||||
/** Filter values, keyed the way the books endpoint expects them. */
|
||||
filters: Record<string, string[]>;
|
||||
/** Backend sort field; omitted means "leave the current ordering alone". */
|
||||
orderBy?: string;
|
||||
/** asc, desc, or random — the API's CustomOrderBy accepts all three. */
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
export const BOOK_PRESETS: BookPreset[] = [
|
||||
{
|
||||
id: 'continue-reading',
|
||||
label: 'Reading',
|
||||
heading: 'Continue Reading',
|
||||
filters: { progress: ['in_progress'] },
|
||||
orderBy: 'last_accessed',
|
||||
sortOrder: 'desc'
|
||||
},
|
||||
{
|
||||
id: 'recently-added',
|
||||
label: 'Recently added',
|
||||
heading: 'Recently Added',
|
||||
filters: { progress: ['unread'] },
|
||||
orderBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
},
|
||||
{
|
||||
id: 'discover',
|
||||
label: 'Discover',
|
||||
heading: 'Discover',
|
||||
filters: { progress: ['unread'] },
|
||||
sortOrder: 'random'
|
||||
},
|
||||
{
|
||||
id: 'read-again',
|
||||
label: 'Read again',
|
||||
heading: 'Read Again',
|
||||
filters: { progress: ['read'] },
|
||||
sortOrder: 'random'
|
||||
}
|
||||
];
|
||||
|
||||
export function getPreset(id: string) {
|
||||
return BOOK_PRESETS.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query string for the books endpoint, used by the home route's loader.
|
||||
*/
|
||||
export function presetQuery(preset: BookPreset, libraryId: string | number) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('libraries', String(libraryId));
|
||||
|
||||
for (const [key, values] of Object.entries(preset.filters)) {
|
||||
for (const value of values) params.append(key, value);
|
||||
}
|
||||
|
||||
if (preset.orderBy) params.set('orderBy', preset.orderBy);
|
||||
if (preset.sortOrder) params.set('sortOrder', preset.sortOrder);
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Hand-written types for the vendored foliate-js (src/lib/vendor/foliate-js).
|
||||
*
|
||||
* `$foliate` is a Vite-only alias, so TypeScript cannot resolve it and this
|
||||
* ambient declaration is the only candidate — which is the point: svelte-check
|
||||
* never walks the untyped vendored JS. Only the surface Chitai calls is typed.
|
||||
* When you start using a new method, add it here rather than casting to `any`.
|
||||
*
|
||||
* The filename must not match a sibling .ts. As foliate.d.ts next to foliate.ts,
|
||||
* TypeScript takes it for that file's emitted declaration and drops it from the
|
||||
* program, and every $foliate import fails with TS2307.
|
||||
*/
|
||||
declare module '$foliate/view.js' {
|
||||
/** A TOC entry. `id` is assigned by foliate's `assignIDs`, not by the book. */
|
||||
export interface FoliateTocItem {
|
||||
id: number;
|
||||
label: string;
|
||||
href: string;
|
||||
subitems?: FoliateTocItem[];
|
||||
}
|
||||
|
||||
/** `event.detail` of the `relocate` event. Shape from SectionProgress.getProgress. */
|
||||
export interface FoliateRelocateDetail {
|
||||
/** Overall progress through the book, 0–1. Chitai stores this as `percentage`. */
|
||||
fraction: number;
|
||||
section: { current: number; total: number };
|
||||
location: { current: number; next: number; total: number };
|
||||
/** Estimated remaining reading time, in minutes. */
|
||||
time: { section: number; total: number };
|
||||
/** Null when the book has no TOC entry covering this position. */
|
||||
tocItem?: FoliateTocItem | null;
|
||||
pageItem?: { label: string; href: string } | null;
|
||||
cfi: string;
|
||||
range?: Range;
|
||||
}
|
||||
|
||||
/** `event.detail` of the `load` event. `doc` is the section's iframe document. */
|
||||
export interface FoliateLoadDetail {
|
||||
doc: Document;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface FoliateRenderer extends HTMLElement {
|
||||
/**
|
||||
* A single string sets the stylesheet appended to <head>, which wins over the
|
||||
* book's own CSS on equal specificity. A [before, after] tuple also sets one
|
||||
* prepended to <head>, which the book's CSS overrides. Re-applied by the
|
||||
* paginator on every section load. Absent on the fixed-layout renderer.
|
||||
*/
|
||||
setStyles?(styles: string | [before: string, after: string]): void;
|
||||
getContents(): { doc: Document; index: number }[];
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface FoliateBook {
|
||||
toc?: FoliateTocItem[];
|
||||
/** 'rtl' for right-to-left books; drives goLeft/goRight. */
|
||||
dir?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
rendition?: { layout?: string };
|
||||
}
|
||||
|
||||
/** Accepted by goTo/select/init: a CFI or href, a spine index, or a fraction. */
|
||||
export type FoliateTarget = string | number | { fraction: number };
|
||||
|
||||
export class View extends HTMLElement {
|
||||
book: FoliateBook;
|
||||
renderer: FoliateRenderer;
|
||||
/** True when the book is pre-paginated; the renderer is then foliate-fxl. */
|
||||
isFixedLayout: boolean;
|
||||
lastLocation: FoliateRelocateDetail | null;
|
||||
|
||||
open(book: File | string | FoliateBook): Promise<void>;
|
||||
close(): void;
|
||||
init(opts: { lastLocation?: FoliateTarget | null; showTextStart?: boolean }): Promise<void>;
|
||||
|
||||
/** Returns undefined on failure — it logs and swallows. Check before relying on it. */
|
||||
resolveNavigation(target: FoliateTarget): { index: number; anchor?: unknown } | undefined;
|
||||
goTo(target: FoliateTarget): Promise<{ index: number } | undefined>;
|
||||
goToFraction(fraction: number): Promise<void>;
|
||||
|
||||
prev(distance?: number): Promise<void>;
|
||||
next(distance?: number): Promise<void>;
|
||||
/** Direction-aware: inverts against prev/next for rtl books. */
|
||||
goLeft(): Promise<void>;
|
||||
goRight(): Promise<void>;
|
||||
}
|
||||
|
||||
export function makeBook(file: File | string): Promise<FoliateBook>;
|
||||
|
||||
export class ResponseError extends Error {}
|
||||
export class NotFoundError extends Error {}
|
||||
export class UnsupportedTypeError extends Error {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { View } from '$foliate/view.js';
|
||||
|
||||
let pending: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Loads foliate-js and registers its custom elements.
|
||||
*
|
||||
* view.js calls customElements.define() at module scope and subclasses
|
||||
* HTMLElement, and its class fields construct DOM helpers eagerly — so importing
|
||||
* it on the server throws. This module is safe to import anywhere because it
|
||||
* touches nothing at module scope; the vendored code is only pulled in when
|
||||
* loadFoliate() is called, which must be from onMount or a browser guard.
|
||||
*
|
||||
* The promise is cached so concurrent callers share one load. ESM already dedupes
|
||||
* module evaluation; this mainly makes the "define runs once" contract explicit.
|
||||
* Editing a file under src/lib/vendor invalidates the module and re-runs
|
||||
* customElements.define, which throws — hard-refresh after re-vendoring.
|
||||
*/
|
||||
export function loadFoliate(): Promise<void> {
|
||||
return (pending ??= import('$foliate/view.js').then(() => undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a <foliate-view>. Call only after loadFoliate() has resolved.
|
||||
*
|
||||
* Built imperatively rather than written in markup so SSR never emits an unknown
|
||||
* element for Svelte to hydrate, and so svelte-check has no unknown attributes to
|
||||
* complain about.
|
||||
*/
|
||||
export function createFoliateView(): View {
|
||||
return document.createElement('foliate-view') as View;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const PURGED_FLAG = 'chitai:locations-purged';
|
||||
|
||||
/** Keys the epub.js reader wrote: `${bookId}-locations`. */
|
||||
const LEGACY_KEY = /^\d+-locations$/;
|
||||
|
||||
/**
|
||||
* Drops the epub.js locations cache, once per browser.
|
||||
*
|
||||
* foliate computes progress from section byte sizes at open time, so there is no
|
||||
* locations pre-pass and nothing to cache. The old entries are not small — a
|
||||
* few hundred KB of JSON per long book against a 5–10 MB origin quota — and a
|
||||
* heavy reader sitting near the cap would make the new settings write throw
|
||||
* QuotaExceededError.
|
||||
*
|
||||
* Removable once deployments have had a release to run it; see TODO.md.
|
||||
*/
|
||||
export function purgeLegacyLocationCache() {
|
||||
try {
|
||||
if (localStorage.getItem(PURGED_FLAG)) return;
|
||||
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (LEGACY_KEY.test(key)) localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
localStorage.setItem(PURGED_FLAG, '1');
|
||||
} catch (error) {
|
||||
console.warn('Could not purge the legacy locations cache', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
interface PendingProgress {
|
||||
percentage: number;
|
||||
epub_cfi: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/** Treat the last stretch as finished: fraction is a float and never lands on 1. */
|
||||
const COMPLETE_AT = 0.99;
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 3000;
|
||||
|
||||
/**
|
||||
* Debounced reading-progress writer.
|
||||
*
|
||||
* Progress goes through the catch-all proxy rather than a remote function. That
|
||||
* looks like a convention violation but is the documented exception: the browser
|
||||
* itself must send this, and the closing write uses sendBeacon, which needs a
|
||||
* plain URL and body rather than a remote command's envelope. The httpOnly
|
||||
* authToken cookie rides along and the proxy attaches the bearer header.
|
||||
*/
|
||||
export class ProgressReporter {
|
||||
#url: string;
|
||||
#timer: ReturnType<typeof setTimeout> | undefined;
|
||||
#pending: PendingProgress | null = null;
|
||||
#listening = false;
|
||||
|
||||
constructor(bookId: string | number) {
|
||||
this.#url = `/api/books/progress/${bookId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts flushing on the way out.
|
||||
*
|
||||
* pagehide and a hidden visibilitychange, not beforeunload: mobile Safari
|
||||
* fires beforeunload unreliably and it blocks the bfcache.
|
||||
*/
|
||||
listen() {
|
||||
if (this.#listening || typeof document === 'undefined') return;
|
||||
document.addEventListener('visibilitychange', this.#onVisibilityChange);
|
||||
window.addEventListener('pagehide', this.#onPageHide);
|
||||
this.#listening = true;
|
||||
}
|
||||
|
||||
record(percentage: number, epubCfi: string) {
|
||||
this.#pending = {
|
||||
percentage,
|
||||
epub_cfi: epubCfi,
|
||||
completed: percentage >= COMPLETE_AT
|
||||
};
|
||||
|
||||
clearTimeout(this.#timer);
|
||||
this.#timer = setTimeout(() => void this.flush(), SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** Sends anything outstanding and waits for it. */
|
||||
async flush() {
|
||||
const body = this.#take();
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(this.#url, {
|
||||
method: 'POST',
|
||||
// Without this the browser stamps text/plain and the proxy forwards it
|
||||
// verbatim. Litestar decodes it anyway, which is why it went unnoticed.
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
keepalive: true
|
||||
});
|
||||
|
||||
// A failed save must not interrupt reading, but should not be silent either.
|
||||
if (!response.ok) console.error('Could not save reading progress', response.status);
|
||||
} catch (error) {
|
||||
console.error('Could not save reading progress', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget flush for teardown.
|
||||
*
|
||||
* The old reader cleared its debounce timer on destroy without flushing, so a
|
||||
* page turn within three seconds of leaving was dropped — and leaving is
|
||||
* exactly when someone turns a last page.
|
||||
*/
|
||||
flushSync() {
|
||||
const body = this.#take();
|
||||
if (!body) return;
|
||||
|
||||
const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
|
||||
if (!navigator.sendBeacon?.(this.#url, blob)) void this.flush();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.flushSync();
|
||||
clearTimeout(this.#timer);
|
||||
if (!this.#listening) return;
|
||||
document.removeEventListener('visibilitychange', this.#onVisibilityChange);
|
||||
window.removeEventListener('pagehide', this.#onPageHide);
|
||||
this.#listening = false;
|
||||
}
|
||||
|
||||
/** Claims the pending write so it cannot be sent twice. */
|
||||
#take(): PendingProgress | null {
|
||||
const body = this.#pending;
|
||||
this.#pending = null;
|
||||
clearTimeout(this.#timer);
|
||||
return body;
|
||||
}
|
||||
|
||||
#onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') this.flushSync();
|
||||
};
|
||||
|
||||
#onPageHide = () => this.flushSync();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { FONT_STACKS } from '$lib/theme/presets';
|
||||
import { readerSettingsSchema, type ReaderSettings } from '$lib/schema/reader';
|
||||
|
||||
export const READER_SETTINGS_STORAGE_KEY = 'chitai:reader-settings';
|
||||
|
||||
/** Georgia — the app's own title face, and a reasonable default for long prose. */
|
||||
export const DEFAULT_READER_SETTINGS: ReaderSettings = {
|
||||
fontFamily: FONT_STACKS[1].value,
|
||||
fontSize: 18,
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.6,
|
||||
letterSpacing: 0,
|
||||
margin: 48,
|
||||
gap: 6,
|
||||
// foliate caps the reading area at this times the column count, so 1000 gives
|
||||
// a 2000px spread — enough to fill a large display rather than leaving the
|
||||
// book as a narrow strip. Its own default of 720 is noticeably tight.
|
||||
maxInlineSize: 1000,
|
||||
maxColumnCount: 2,
|
||||
flow: 'paginated',
|
||||
justify: true,
|
||||
hyphenate: true
|
||||
};
|
||||
|
||||
/** Reused from the app theme: system stacks only, so nothing silently falls back. */
|
||||
export const READER_FONT_OPTIONS = FONT_STACKS;
|
||||
|
||||
/** Slider bounds, kept next to the schema they mirror. */
|
||||
export const READER_BOUNDS = {
|
||||
fontSize: { min: 12, max: 32, step: 1 },
|
||||
fontWeight: { min: 300, max: 700, step: 100 },
|
||||
lineHeight: { min: 1, max: 2.5, step: 0.05 },
|
||||
letterSpacing: { min: -0.05, max: 0.2, step: 0.01 },
|
||||
margin: { min: 0, max: 120, step: 4 },
|
||||
gap: { min: 0, max: 15, step: 1 },
|
||||
maxInlineSize: { min: 400, max: 2400, step: 20 }
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Reads stored settings, falling back to defaults on anything unusable.
|
||||
*
|
||||
* Unknown keys are stripped and missing ones filled, so settings added in a
|
||||
* later release do not invalidate what a reader already has stored.
|
||||
*/
|
||||
export function loadReaderSettings(): ReaderSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(READER_SETTINGS_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_READER_SETTINGS;
|
||||
const parsed = readerSettingsSchema.safeParse({
|
||||
...DEFAULT_READER_SETTINGS,
|
||||
...JSON.parse(raw)
|
||||
});
|
||||
return parsed.success ? parsed.data : DEFAULT_READER_SETTINGS;
|
||||
} catch {
|
||||
return DEFAULT_READER_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveReaderSettings(settings: ReaderSettings) {
|
||||
try {
|
||||
localStorage.setItem(READER_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
// A full quota must not stop anyone reading.
|
||||
console.warn('Could not save reader settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The half of the settings that foliate takes as renderer attributes.
|
||||
*
|
||||
* The renderer has no JS property API — these must be set with setAttribute.
|
||||
* Note there is no `margin` shorthand upstream, and no `spread`: on a reflowable
|
||||
* book a two-page spread is max-column-count 2.
|
||||
*/
|
||||
export function toRendererAttributes(s: ReaderSettings): Record<string, string> {
|
||||
return {
|
||||
flow: s.flow,
|
||||
gap: `${s.gap}%`,
|
||||
'margin-top': `${s.margin}px`,
|
||||
'margin-bottom': `${s.margin}px`,
|
||||
'margin-left': `${s.margin}px`,
|
||||
'margin-right': `${s.margin}px`,
|
||||
'max-inline-size': `${s.maxInlineSize}px`,
|
||||
'max-column-count': String(s.maxColumnCount)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ReaderSettings } from '$lib/schema/reader';
|
||||
|
||||
export interface ReaderPalette {
|
||||
bg: string;
|
||||
fg: string;
|
||||
muted: string;
|
||||
link: string;
|
||||
dark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the app palette out of the live document.
|
||||
*
|
||||
* The reader route uses a +layout@ breakout, so it never runs (root)/+layout and
|
||||
* cannot reach the theme context. It does not need to: hooks.server.ts inlines
|
||||
* <style id="chitai-theme"> with the full palette into every page, so the tokens
|
||||
* are resolved on documentElement before first paint.
|
||||
*
|
||||
* `dark` is passed in rather than read off the class list, because mode-watcher's
|
||||
* store is the authority and the class trails it by a frame. Call this after that
|
||||
* frame, though — the token values themselves do come from the class.
|
||||
*/
|
||||
export function readReaderPalette(dark: boolean): ReaderPalette {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const token = (name: string, fallback: string) =>
|
||||
styles.getPropertyValue(name).trim() || fallback;
|
||||
|
||||
return {
|
||||
// --card rather than --background: the book is a sheet of paper on the page.
|
||||
bg: token('--card', '#ffffff'),
|
||||
fg: token('--foreground', '#000000'),
|
||||
muted: token('--muted-foreground', '#666666'),
|
||||
link: token('--primary', '#0066cc'),
|
||||
dark
|
||||
};
|
||||
}
|
||||
|
||||
/** Body-text containers. Headings are excluded so their scale survives. */
|
||||
const TEXT = 'p, li, dd, dt, blockquote, td, th, div';
|
||||
|
||||
const HEADINGS = 'h1, h2, h3, h4, h5, h6';
|
||||
|
||||
/**
|
||||
* Builds the two stylesheets foliate injects into each section document.
|
||||
*
|
||||
* The paginator creates two <style> elements per document: the first is
|
||||
* head.prepend()ed, so the book's own CSS overrides it; the second is
|
||||
* head.append()ed, so it wins. setStyles takes the pair as a tuple and
|
||||
* re-applies it on every section load.
|
||||
*
|
||||
* Nearly everything goes in `after`, with !important. These are the reader's
|
||||
* settings, and EPUBs routinely set their own font-family, font-size and
|
||||
* line-height on body and on paragraphs — put the settings in `before` and the
|
||||
* book simply overrides them, which is what made the typography controls look
|
||||
* like they did nothing.
|
||||
*
|
||||
* `before` is left for the few defaults a book should be free to override.
|
||||
*/
|
||||
export function buildReaderStyles(
|
||||
s: ReaderSettings,
|
||||
p: ReaderPalette
|
||||
): [before: string, after: string] {
|
||||
const hyphens = s.hyphenate ? 'auto' : 'manual';
|
||||
|
||||
const before = `
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
|
||||
${TEXT} {
|
||||
-webkit-hyphenate-limit-before: 3;
|
||||
-webkit-hyphenate-limit-after: 2;
|
||||
-webkit-hyphenate-limit-lines: 2;
|
||||
hanging-punctuation: allow-end last;
|
||||
widows: 2;
|
||||
orphans: 2;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const after = `
|
||||
/* The rem base. Headings sized in em/rem keep their scale relative to it. */
|
||||
html {
|
||||
font-size: ${s.fontSize}px !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 1rem !important;
|
||||
font-weight: ${s.fontWeight} !important;
|
||||
}
|
||||
|
||||
/* inherit rather than a fixed size: books that set an absolute size in pt
|
||||
or px on their paragraphs would otherwise ignore the size setting
|
||||
entirely, while headings keep whatever relative scale they declare. */
|
||||
${TEXT} {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
body, ${TEXT}, ${HEADINGS}, figcaption, caption {
|
||||
font-family: ${s.fontFamily} !important;
|
||||
}
|
||||
|
||||
body, ${TEXT} {
|
||||
line-height: ${s.lineHeight} !important;
|
||||
letter-spacing: ${s.letterSpacing}em !important;
|
||||
}
|
||||
|
||||
p, li, dd, blockquote {
|
||||
text-align: ${s.justify ? 'justify' : 'start'} !important;
|
||||
-webkit-hyphens: ${hyphens} !important;
|
||||
hyphens: ${hyphens} !important;
|
||||
}
|
||||
|
||||
/* Justification must not silently override an explicit align attribute. */
|
||||
[align="left"] { text-align: left !important; }
|
||||
[align="right"] { text-align: right !important; }
|
||||
[align="center"] { text-align: center !important; }
|
||||
[align="justify"] { text-align: justify !important; }
|
||||
|
||||
/* Tells the book's own prefers-color-scheme rules which way we are going.
|
||||
Without it the paginator's media query follows the OS, so a book with
|
||||
dark styles can invert against the app. */
|
||||
html {
|
||||
color-scheme: ${p.dark ? 'dark' : 'light'};
|
||||
}
|
||||
|
||||
/* Most EPUBs set their own body background and colour, so an
|
||||
unprioritised override loses to them and the page stays white. */
|
||||
html, body {
|
||||
background: ${p.bg} !important;
|
||||
color: ${p.fg} !important;
|
||||
}
|
||||
|
||||
${TEXT}, ${HEADINGS}, span, dl, figcaption, caption {
|
||||
color: ${p.fg} !important;
|
||||
}
|
||||
|
||||
a:any-link {
|
||||
color: ${p.link} !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: ${p.muted} !important;
|
||||
}
|
||||
|
||||
/* Keep line art and diagrams legible on a dark page without touching
|
||||
photographs, which invert badly. */
|
||||
${
|
||||
p.dark
|
||||
? `svg { color: ${p.fg}; }
|
||||
img[src$=".svg"] { filter: invert(1) hue-rotate(180deg); }`
|
||||
: ''
|
||||
}
|
||||
|
||||
img, svg, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
return [before, after];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Persisted reader preferences.
|
||||
*
|
||||
* Validated on read, not just on write: several of these values are written
|
||||
* straight onto foliate's renderer as custom-element attributes, where a bad
|
||||
* value silently wedges the layout instead of throwing. Anything that fails
|
||||
* parsing falls back to the defaults.
|
||||
*/
|
||||
export const readerFlowSchema = z.enum(['paginated', 'scrolled']);
|
||||
|
||||
export const readerSettingsSchema = z.object({
|
||||
fontFamily: z.string().min(1).max(200),
|
||||
/** px, applied to <html> so the book's own rem/em headings scale with it. */
|
||||
fontSize: z.number().int().min(12).max(32),
|
||||
fontWeight: z.number().int().min(300).max(700),
|
||||
lineHeight: z.number().min(1).max(2.5),
|
||||
/** em */
|
||||
letterSpacing: z.number().min(-0.05).max(0.2),
|
||||
/** px, applied to all four renderer margins. */
|
||||
margin: z.number().int().min(0).max(120),
|
||||
/** % of the viewport, the space between columns. */
|
||||
gap: z.number().int().min(0).max(15),
|
||||
/**
|
||||
* px, the maximum width of a single column. foliate caps the whole reading
|
||||
* area at this times the column count, so on a wide screen it is what decides
|
||||
* how much of the window the book actually uses.
|
||||
*/
|
||||
maxInlineSize: z.number().int().min(400).max(2400),
|
||||
/** 1 for a single page, 2 for a spread. Reflowable books only. */
|
||||
maxColumnCount: z.number().int().min(1).max(2),
|
||||
flow: readerFlowSchema,
|
||||
justify: z.boolean(),
|
||||
hyphenate: z.boolean()
|
||||
});
|
||||
|
||||
export type ReaderSettings = z.infer<typeof readerSettingsSchema>;
|
||||
export type ReaderFlow = z.infer<typeof readerFlowSchema>;
|
||||
@@ -3,10 +3,22 @@ import { goto, pushState, replaceState } from '$app/navigation';
|
||||
import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema';
|
||||
import { page } from '$app/state';
|
||||
import { BookOperationsState } from './bookOperations.svelte';
|
||||
import type { BookPreset } from '$lib/presets';
|
||||
|
||||
/** The browse views, shared with view-toggle.svelte so the two cannot drift. */
|
||||
export const BOOK_VIEWS = ['grid', 'list', 'table'] as const;
|
||||
|
||||
export type BookView = (typeof BOOK_VIEWS)[number];
|
||||
|
||||
/** Anything unrecognised falls back to grid rather than blanking the page. */
|
||||
export function parseView(value: string | null | undefined): BookView {
|
||||
return BOOK_VIEWS.includes(value as BookView) ? (value as BookView) : 'grid';
|
||||
}
|
||||
|
||||
export class BookCollectionState {
|
||||
public sortOrder = $state<string>('');
|
||||
public orderBy = $state<string>('');
|
||||
public view = $state<BookView>('grid');
|
||||
public filters = $state<Record<string, string[]>>({});
|
||||
|
||||
readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc');
|
||||
@@ -58,6 +70,10 @@ export class BookCollectionState {
|
||||
this.orderBy = page.url.searchParams.get('orderBy') || 'title';
|
||||
this.sortOrder = page.url.searchParams.get('sortOrder') || 'asc';
|
||||
|
||||
// Read during SSR too, so a shared ?view=table link renders the table on
|
||||
// the server rather than flashing the grid first.
|
||||
this.view = parseView(page.url.searchParams.get('view'));
|
||||
|
||||
// Construct initial filters based on the page data
|
||||
this.filters = {
|
||||
authors: page.url.searchParams.getAll('authors'),
|
||||
@@ -68,6 +84,27 @@ export class BookCollectionState {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow routing: replaceState updates the URL and page.url without running
|
||||
* any load function. Switching view changes presentation only, so it must not
|
||||
* take the updateSearchParams path — that calls goto() and refetches the list.
|
||||
*
|
||||
* replaceState rather than pushState because a view is a preference, not a
|
||||
* destination; Back should leave the page, not step through view changes.
|
||||
*/
|
||||
setView(next: BookView) {
|
||||
if (next === this.view) return;
|
||||
|
||||
this.view = next;
|
||||
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('view', next);
|
||||
// Not a route to resolve — this is the current URL with one query param
|
||||
// changed, so it is already fully qualified.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
replaceState(url, page.state);
|
||||
}
|
||||
|
||||
updateSort(sortValue: string) {
|
||||
if (sortValue === this.orderBy) {
|
||||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
@@ -103,7 +140,8 @@ export class BookCollectionState {
|
||||
url.searchParams.set('orderBy', this.orderBy);
|
||||
url.searchParams.set('sortOrder', this.sortOrder);
|
||||
|
||||
// pushState(url.toString(), {})
|
||||
// Same as setView: the current URL with sort params rewritten, not a route.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(url.toString());
|
||||
|
||||
this.loadNewBooks();
|
||||
@@ -209,6 +247,52 @@ export class BookCollectionState {
|
||||
this.updateSearchParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* A preset is a whole view, not an extra filter — applying one replaces the
|
||||
* filters and the sort. Accumulating them instead produces empty results the
|
||||
* moment two overlap (Reading plus Unread returns nothing) with no visible
|
||||
* reason why.
|
||||
*/
|
||||
applyPreset(preset: BookPreset) {
|
||||
Object.values(this.filters).forEach((val) => (val.length = 0));
|
||||
|
||||
for (const [key, values] of Object.entries(preset.filters)) {
|
||||
this.filters[key] = [...values];
|
||||
}
|
||||
|
||||
this.orderBy = preset.orderBy ?? 'title';
|
||||
this.sortOrder = preset.sortOrder ?? 'asc';
|
||||
|
||||
this.updateSearchParams();
|
||||
}
|
||||
|
||||
/** Back to the unfiltered, title-sorted default. */
|
||||
clearView() {
|
||||
Object.values(this.filters).forEach((val) => (val.length = 0));
|
||||
this.orderBy = 'title';
|
||||
this.sortOrder = 'asc';
|
||||
this.updateSearchParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active only on an exact match. Adding a tag on top of a preset deselects
|
||||
* the chip while keeping the filters — the chip stops claiming to describe a
|
||||
* view it no longer describes.
|
||||
*/
|
||||
isPresetActive(preset: BookPreset) {
|
||||
if (this.orderBy !== (preset.orderBy ?? 'title')) return false;
|
||||
if (this.sortOrder !== (preset.sortOrder ?? 'asc')) return false;
|
||||
|
||||
const active = Object.entries(this.filters).filter(([, values]) => values.length > 0);
|
||||
const wanted = Object.entries(preset.filters);
|
||||
if (active.length !== wanted.length) return false;
|
||||
|
||||
return wanted.every(([key, values]) => {
|
||||
const current = this.filters[key] ?? [];
|
||||
return current.length === values.length && values.every((value) => current.includes(value));
|
||||
});
|
||||
}
|
||||
|
||||
isFilterSelected(filter: string, value: string) {
|
||||
return this.filters[filter]?.includes(value) || false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
@@ -31,7 +32,9 @@ export class LibraryState {
|
||||
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
|
||||
if (browser) {
|
||||
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
|
||||
await goto(`/library/${libraryId}/view`, { invalidate: ['app:libraries'] });
|
||||
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), {
|
||||
invalidate: ['app:libraries']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
import type { ReaderSettings } from '$lib/schema/reader';
|
||||
import {
|
||||
DEFAULT_READER_SETTINGS,
|
||||
loadReaderSettings,
|
||||
saveReaderSettings,
|
||||
toRendererAttributes
|
||||
} from '$lib/reader/settings';
|
||||
|
||||
export class ReaderSettingsState {
|
||||
settings = $state<ReaderSettings>(DEFAULT_READER_SETTINGS);
|
||||
|
||||
/** The half foliate takes as renderer attributes. */
|
||||
readonly attributes = $derived(toRendererAttributes(this.settings));
|
||||
|
||||
constructor() {
|
||||
// Defaults during SSR, real values once the browser has localStorage. The
|
||||
// reader only applies these after mount, so there is nothing to flash.
|
||||
if (browser) this.settings = loadReaderSettings();
|
||||
}
|
||||
|
||||
set<K extends keyof ReaderSettings>(key: K, value: ReaderSettings[K]) {
|
||||
this.settings = { ...this.settings, [key]: value };
|
||||
saveReaderSettings(this.settings);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.settings = DEFAULT_READER_SETTINGS;
|
||||
saveReaderSettings(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
const READER_SETTINGS_KEY = Symbol('READER_SETTINGS');
|
||||
|
||||
export function setReaderSettingsState() {
|
||||
return setContext(READER_SETTINGS_KEY, new ReaderSettingsState());
|
||||
}
|
||||
|
||||
export function getReaderSettingsState() {
|
||||
return getContext<ReturnType<typeof setReaderSettingsState>>(READER_SETTINGS_KEY);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { invalidate } from '$app/navigation';
|
||||
|
||||
import type { Book, PaginatedResponse } from '$lib/schema';
|
||||
|
||||
export type UploadStatus = 'queued' | 'uploading' | 'done' | 'failed';
|
||||
|
||||
export interface UploadJob {
|
||||
id: string;
|
||||
/** The book's folder, or the filename for a book that is a single file. */
|
||||
label: string;
|
||||
libraryId: number | string;
|
||||
files: File[];
|
||||
size: number;
|
||||
status: UploadStatus;
|
||||
error?: string;
|
||||
book?: Book;
|
||||
}
|
||||
|
||||
export interface UploadSummary {
|
||||
created: number;
|
||||
failed: number;
|
||||
firstBook?: Book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the grouping in BookService.create_books_from_files: files are grouped
|
||||
* by their parent directory, except those at the root, which each become their
|
||||
* own book. Diverging from it would split one book across two records.
|
||||
*/
|
||||
function groupByBook(files: File[]): [key: string, files: File[]][] {
|
||||
// A plain record rather than a Map: this is a throwaway local, and Svelte's
|
||||
// lint rule steers any Map towards the reactive SvelteMap.
|
||||
const groups: Record<string, File[]> = {};
|
||||
|
||||
for (const file of files) {
|
||||
const cut = file.name.lastIndexOf('/');
|
||||
const key = cut === -1 ? file.name : file.name.slice(0, cut);
|
||||
|
||||
(groups[key] ??= []).push(file);
|
||||
}
|
||||
|
||||
return Object.entries(groups);
|
||||
}
|
||||
|
||||
function label(key: string) {
|
||||
const cut = key.lastIndexOf('/');
|
||||
return cut === -1 ? key : key.slice(cut + 1);
|
||||
}
|
||||
|
||||
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
/** How long a clean run stays on screen before clearing itself. */
|
||||
const DISMISS_AFTER_MS = 6000;
|
||||
|
||||
/**
|
||||
* Uploads books one request at a time and keeps the result on screen.
|
||||
*
|
||||
* Lives above the dialog so a run outlives it — the dialog only adds to the
|
||||
* queue, and the tray in the root layout is what reports on it. This is why
|
||||
* uploads do not go through a remote function: one request per book is what
|
||||
* makes progress real, bounds how much any single request buffers, and stops
|
||||
* one bad file taking the whole import with it.
|
||||
*/
|
||||
export class UploadQueueState {
|
||||
jobs = $state<UploadJob[]>([]);
|
||||
collapsed = $state(false);
|
||||
|
||||
readonly total = $derived(this.jobs.length);
|
||||
readonly done = $derived(this.jobs.filter((job) => job.status === 'done').length);
|
||||
readonly failed = $derived(this.jobs.filter((job) => job.status === 'failed').length);
|
||||
readonly active = $derived(
|
||||
this.jobs.some((job) => job.status === 'queued' || job.status === 'uploading')
|
||||
);
|
||||
readonly current = $derived(this.jobs.find((job) => job.status === 'uploading'));
|
||||
readonly settled = $derived(this.done + this.failed);
|
||||
|
||||
#running = false;
|
||||
#dismissTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
#held = false;
|
||||
|
||||
/** Adds one job per book and starts the runner if it is not already going. */
|
||||
enqueue(
|
||||
libraryId: number | string,
|
||||
files: File[],
|
||||
onFinished?: (summary: UploadSummary) => void
|
||||
) {
|
||||
const added = groupByBook(files).map(([key, group]) => ({
|
||||
id: `${libraryId}:${key}`,
|
||||
label: label(key),
|
||||
libraryId,
|
||||
files: group,
|
||||
size: group.reduce((sum, file) => sum + file.size, 0),
|
||||
status: 'queued' as UploadStatus
|
||||
}));
|
||||
|
||||
if (added.length === 0) return;
|
||||
|
||||
clearTimeout(this.#dismissTimer);
|
||||
|
||||
// A finished run stays on screen until dismissed; adding to it starts fresh
|
||||
// rather than appending to a report the reader has already read.
|
||||
if (!this.active) this.jobs = [];
|
||||
|
||||
this.jobs = [...this.jobs, ...added];
|
||||
this.collapsed = false;
|
||||
|
||||
void this.#run(onFinished);
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
if (this.active) return;
|
||||
clearTimeout(this.#dismissTimer);
|
||||
this.jobs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds a finished run on screen while the pointer is over it.
|
||||
*
|
||||
* Without this the tray can vanish from under a reader who is part-way
|
||||
* through reading which books were added.
|
||||
*/
|
||||
hold() {
|
||||
this.#held = true;
|
||||
clearTimeout(this.#dismissTimer);
|
||||
}
|
||||
|
||||
release() {
|
||||
this.#held = false;
|
||||
if (!this.active && this.failed === 0 && this.total > 0) this.#scheduleDismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears itself only when everything worked. A run with failures stays until
|
||||
* dismissed — it is the only record of what did not make it in, and the only
|
||||
* place to retry from.
|
||||
*/
|
||||
#scheduleDismiss() {
|
||||
clearTimeout(this.#dismissTimer);
|
||||
if (this.#held) return;
|
||||
|
||||
this.#dismissTimer = setTimeout(() => {
|
||||
if (!this.active && this.failed === 0) this.jobs = [];
|
||||
}, DISMISS_AFTER_MS);
|
||||
}
|
||||
|
||||
/** Re-queues everything that failed, so one bad book is not a reason to start over. */
|
||||
retryFailed() {
|
||||
this.jobs = this.jobs.map((job) =>
|
||||
job.status === 'failed' ? { ...job, status: 'queued' as UploadStatus, error: undefined } : job
|
||||
);
|
||||
void this.#run();
|
||||
}
|
||||
|
||||
async #run(onFinished?: (summary: UploadSummary) => void) {
|
||||
if (this.#running) return;
|
||||
this.#running = true;
|
||||
|
||||
// Nothing here is resumable, so leaving mid-run loses whatever is left.
|
||||
window.addEventListener('beforeunload', warnBeforeUnload);
|
||||
|
||||
let created = 0;
|
||||
let firstBook: Book | undefined;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const index = this.jobs.findIndex((job) => job.status === 'queued');
|
||||
if (index === -1) break;
|
||||
|
||||
this.#patch(index, { status: 'uploading' });
|
||||
|
||||
try {
|
||||
const body = new FormData();
|
||||
for (const file of this.jobs[index].files) body.append('files', file);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/books/fromFiles?library_id=${encodeURIComponent(String(this.jobs[index].libraryId))}`,
|
||||
{ method: 'POST', body }
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`The server returned ${response.status}`);
|
||||
|
||||
const result: PaginatedResponse<Book> = await response.json();
|
||||
const book = result.items[0];
|
||||
|
||||
created += result.total ?? result.items.length;
|
||||
firstBook ??= book;
|
||||
|
||||
this.#patch(index, { status: 'done', book });
|
||||
} catch (error) {
|
||||
// One bad book must not take the rest of the queue with it.
|
||||
console.error(`Failed to upload ${this.jobs[index].label}`, error);
|
||||
this.#patch(index, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Upload failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#running = false;
|
||||
window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||
}
|
||||
|
||||
if (created > 0) await invalidate('app:books');
|
||||
|
||||
if (this.failed === 0) this.#scheduleDismiss();
|
||||
|
||||
onFinished?.({ created, failed: this.failed, firstBook });
|
||||
}
|
||||
|
||||
#patch(index: number, changes: Partial<UploadJob>) {
|
||||
this.jobs[index] = { ...this.jobs[index], ...changes };
|
||||
}
|
||||
}
|
||||
|
||||
const UPLOAD_QUEUE_KEY = Symbol('UPLOAD_QUEUE');
|
||||
|
||||
export function setUploadQueueState() {
|
||||
return setContext(UPLOAD_QUEUE_KEY, new UploadQueueState());
|
||||
}
|
||||
|
||||
export function getUploadQueueState() {
|
||||
return getContext<ReturnType<typeof setUploadQueueState>>(UPLOAD_QUEUE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Theme presets and the definition of what the editor may change.
|
||||
*
|
||||
* A preset supplies a full palette for both modes. The editor writes sparse
|
||||
* overrides on top, per mode, so customising the dark palette never disturbs
|
||||
* the light one.
|
||||
*/
|
||||
|
||||
export type Mode = 'light' | 'dark';
|
||||
|
||||
/** CSS custom properties the editor exposes, in the order it shows them. */
|
||||
export const COLOR_TOKENS = [
|
||||
{ key: 'background', label: 'Background', hint: 'Page behind everything' },
|
||||
{ key: 'foreground', label: 'Text', hint: 'Default body text' },
|
||||
{ key: 'card', label: 'Surface', hint: 'Cards, rows, popovers' },
|
||||
{ key: 'primary', label: 'Primary', hint: 'Buttons and active states' },
|
||||
{ key: 'muted-foreground', label: 'Muted text', hint: 'Counts, captions, metadata' },
|
||||
{ key: 'border', label: 'Border', hint: 'Rules and outlines' },
|
||||
{ key: 'sidebar', label: 'Sidebar', hint: 'Sidebar and toolbar ground' },
|
||||
{ key: 'success', label: 'Finished', hint: 'Completed reading progress' },
|
||||
{ key: 'flag', label: 'Active filter', hint: 'The dot on Filter and Sort' },
|
||||
{ key: 'star', label: 'Favourite', hint: 'Stars and selection rings' },
|
||||
{ key: 'destructive', label: 'Destructive', hint: 'Delete actions' }
|
||||
] as const;
|
||||
|
||||
export type ColorTokenKey = (typeof COLOR_TOKENS)[number]['key'];
|
||||
|
||||
export const FONT_TOKENS = [
|
||||
{ key: 'app-font-sans', label: 'Interface' },
|
||||
{ key: 'app-font-serif', label: 'Titles' },
|
||||
{ key: 'app-font-mono', label: 'Numbers and labels' }
|
||||
] as const;
|
||||
|
||||
export type FontTokenKey = (typeof FONT_TOKENS)[number]['key'];
|
||||
|
||||
/** System stacks only — no webfont fetch, so nothing can silently fall back. */
|
||||
export const FONT_STACKS: { label: string; value: string }[] = [
|
||||
{
|
||||
label: 'System UI',
|
||||
value: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
|
||||
},
|
||||
{ label: 'Georgia', value: "Georgia, 'Iowan Old Style', 'Times New Roman', serif" },
|
||||
{ label: 'Times', value: "'Times New Roman', Times, serif" },
|
||||
{ label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" },
|
||||
{ label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" },
|
||||
{ label: 'Verdana', value: 'Verdana, Geneva, sans-serif' },
|
||||
{ label: 'Monospace', value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" }
|
||||
];
|
||||
|
||||
export type Palette = Record<string, string>;
|
||||
|
||||
export interface Preset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
radius: string;
|
||||
fonts: Record<FontTokenKey, string>;
|
||||
light: Palette;
|
||||
dark: Palette;
|
||||
}
|
||||
|
||||
const SANS = FONT_STACKS[0].value;
|
||||
const SERIF = FONT_STACKS[1].value;
|
||||
const MONO = FONT_STACKS[6].value;
|
||||
|
||||
export const READING_ROOM: Preset = {
|
||||
id: 'reading-room',
|
||||
name: 'Reading Room',
|
||||
description: 'Cool grey paper, one teal accent, serif titles.',
|
||||
radius: '0.625rem',
|
||||
fonts: { 'app-font-sans': SANS, 'app-font-serif': SERIF, 'app-font-mono': MONO },
|
||||
light: {
|
||||
background: '#e9ebee',
|
||||
foreground: '#1b1f24',
|
||||
card: '#fbfbfc',
|
||||
'card-foreground': '#1b1f24',
|
||||
popover: '#fbfbfc',
|
||||
'popover-foreground': '#1b1f24',
|
||||
primary: '#1f5f5b',
|
||||
'primary-foreground': '#f2f7f6',
|
||||
secondary: '#dfe3e8',
|
||||
'secondary-foreground': '#1b1f24',
|
||||
muted: '#e2e5e9',
|
||||
'muted-foreground': '#7d858f',
|
||||
accent: '#d7e5e3',
|
||||
'accent-foreground': '#164743',
|
||||
destructive: '#a6402f',
|
||||
border: '#d2d6dc',
|
||||
input: '#d2d6dc',
|
||||
ring: '#1f5f5b',
|
||||
success: '#2f7d4f',
|
||||
'success-foreground': '#f2f7f6',
|
||||
flag: '#b08a1e',
|
||||
star: '#c79a25',
|
||||
sidebar: '#e2e5e9',
|
||||
'sidebar-foreground': '#1b1f24',
|
||||
'sidebar-primary': '#1f5f5b',
|
||||
'sidebar-primary-foreground': '#f2f7f6',
|
||||
'sidebar-accent': '#d7e5e3',
|
||||
'sidebar-accent-foreground': '#164743',
|
||||
'sidebar-border': '#d2d6dc',
|
||||
'sidebar-ring': '#1f5f5b'
|
||||
},
|
||||
dark: {
|
||||
background: '#15191b',
|
||||
foreground: '#e6eae9',
|
||||
card: '#1d2226',
|
||||
'card-foreground': '#e6eae9',
|
||||
popover: '#1d2226',
|
||||
'popover-foreground': '#e6eae9',
|
||||
primary: '#6fbab0',
|
||||
'primary-foreground': '#0e1a19',
|
||||
secondary: '#232a2d',
|
||||
'secondary-foreground': '#e6eae9',
|
||||
muted: '#232a2d',
|
||||
'muted-foreground': '#78868a',
|
||||
accent: '#1b3330',
|
||||
'accent-foreground': '#9fd8d0',
|
||||
destructive: '#e0796b',
|
||||
border: '#2a3034',
|
||||
input: '#2a3034',
|
||||
ring: '#6fbab0',
|
||||
success: '#4fa97a',
|
||||
'success-foreground': '#0e1a19',
|
||||
flag: '#d4a63a',
|
||||
star: '#e5b84b',
|
||||
sidebar: '#111517',
|
||||
'sidebar-foreground': '#e6eae9',
|
||||
'sidebar-primary': '#6fbab0',
|
||||
'sidebar-primary-foreground': '#0e1a19',
|
||||
'sidebar-accent': '#1b3330',
|
||||
'sidebar-accent-foreground': '#9fd8d0',
|
||||
'sidebar-border': '#2a3034',
|
||||
'sidebar-ring': '#6fbab0'
|
||||
}
|
||||
};
|
||||
|
||||
export const STACKS: Preset = {
|
||||
id: 'stacks',
|
||||
name: 'Stacks',
|
||||
description: 'Warm near-black with brass. Covers do the talking.',
|
||||
radius: '0.25rem',
|
||||
fonts: { 'app-font-sans': SANS, 'app-font-serif': SERIF, 'app-font-mono': MONO },
|
||||
light: {
|
||||
...READING_ROOM.light,
|
||||
background: '#f2efe9',
|
||||
foreground: '#221e1a',
|
||||
card: '#fbf9f5',
|
||||
'card-foreground': '#221e1a',
|
||||
popover: '#fbf9f5',
|
||||
'popover-foreground': '#221e1a',
|
||||
primary: '#8a6a15',
|
||||
'primary-foreground': '#fbf9f5',
|
||||
secondary: '#e7e1d6',
|
||||
'secondary-foreground': '#221e1a',
|
||||
muted: '#e7e1d6',
|
||||
'muted-foreground': '#7d7266',
|
||||
accent: '#efe6d2',
|
||||
'accent-foreground': '#5c4708',
|
||||
border: '#ddd5c7',
|
||||
input: '#ddd5c7',
|
||||
ring: '#8a6a15',
|
||||
flag: '#8a6a15',
|
||||
star: '#a8801d',
|
||||
sidebar: '#ebe6dc',
|
||||
'sidebar-foreground': '#221e1a',
|
||||
'sidebar-primary': '#8a6a15',
|
||||
'sidebar-primary-foreground': '#fbf9f5',
|
||||
'sidebar-accent': '#efe6d2',
|
||||
'sidebar-accent-foreground': '#5c4708',
|
||||
'sidebar-border': '#ddd5c7',
|
||||
'sidebar-ring': '#8a6a15'
|
||||
},
|
||||
dark: {
|
||||
...READING_ROOM.dark,
|
||||
background: '#141210',
|
||||
foreground: '#ede7de',
|
||||
card: '#221e1a',
|
||||
'card-foreground': '#ede7de',
|
||||
popover: '#221e1a',
|
||||
'popover-foreground': '#ede7de',
|
||||
primary: '#c9a227',
|
||||
'primary-foreground': '#17130a',
|
||||
secondary: '#2c2621',
|
||||
'secondary-foreground': '#ede7de',
|
||||
muted: '#2c2621',
|
||||
'muted-foreground': '#7d7266',
|
||||
accent: '#352c14',
|
||||
'accent-foreground': '#e6c452',
|
||||
border: '#2c2621',
|
||||
input: '#2c2621',
|
||||
ring: '#c9a227',
|
||||
flag: '#c9a227',
|
||||
star: '#e6c452',
|
||||
sidebar: '#100e0c',
|
||||
'sidebar-foreground': '#ede7de',
|
||||
'sidebar-primary': '#c9a227',
|
||||
'sidebar-primary-foreground': '#17130a',
|
||||
'sidebar-accent': '#352c14',
|
||||
'sidebar-accent-foreground': '#e6c452',
|
||||
'sidebar-border': '#2c2621',
|
||||
'sidebar-ring': '#c9a227'
|
||||
}
|
||||
};
|
||||
|
||||
export const SLATE: Preset = {
|
||||
id: 'slate',
|
||||
name: 'Slate',
|
||||
description: 'The original shadcn palette, kept for comparison.',
|
||||
radius: '0.625rem',
|
||||
fonts: { 'app-font-sans': SANS, 'app-font-serif': SANS, 'app-font-mono': MONO },
|
||||
light: {
|
||||
...READING_ROOM.light,
|
||||
background: '#ffffff',
|
||||
foreground: '#020617',
|
||||
card: '#ffffff',
|
||||
'card-foreground': '#020617',
|
||||
popover: '#ffffff',
|
||||
'popover-foreground': '#020617',
|
||||
primary: '#1e293b',
|
||||
'primary-foreground': '#f8fafc',
|
||||
secondary: '#f1f5f9',
|
||||
'secondary-foreground': '#1e293b',
|
||||
muted: '#f1f5f9',
|
||||
'muted-foreground': '#64748b',
|
||||
accent: '#f1f5f9',
|
||||
'accent-foreground': '#1e293b',
|
||||
destructive: '#dc2626',
|
||||
border: '#e2e8f0',
|
||||
input: '#e2e8f0',
|
||||
ring: '#94a3b8',
|
||||
success: '#16a34a',
|
||||
flag: '#eab308',
|
||||
star: '#fde047',
|
||||
sidebar: '#f8fafc',
|
||||
'sidebar-foreground': '#020617',
|
||||
'sidebar-primary': '#1e293b',
|
||||
'sidebar-primary-foreground': '#f8fafc',
|
||||
'sidebar-accent': '#f1f5f9',
|
||||
'sidebar-accent-foreground': '#1e293b',
|
||||
'sidebar-border': '#e2e8f0',
|
||||
'sidebar-ring': '#94a3b8'
|
||||
},
|
||||
dark: {
|
||||
...READING_ROOM.dark,
|
||||
background: '#020617',
|
||||
foreground: '#f8fafc',
|
||||
card: '#1e293b',
|
||||
'card-foreground': '#f8fafc',
|
||||
popover: '#1e293b',
|
||||
'popover-foreground': '#f8fafc',
|
||||
primary: '#e2e8f0',
|
||||
'primary-foreground': '#1e293b',
|
||||
secondary: '#334155',
|
||||
'secondary-foreground': '#f8fafc',
|
||||
muted: '#334155',
|
||||
'muted-foreground': '#94a3b8',
|
||||
accent: '#334155',
|
||||
'accent-foreground': '#f8fafc',
|
||||
destructive: '#f87171',
|
||||
border: '#334155',
|
||||
input: '#334155',
|
||||
ring: '#64748b',
|
||||
success: '#4ade80',
|
||||
flag: '#eab308',
|
||||
star: '#fde047',
|
||||
sidebar: '#1e293b',
|
||||
'sidebar-foreground': '#f8fafc',
|
||||
'sidebar-primary': '#60a5fa',
|
||||
'sidebar-primary-foreground': '#f8fafc',
|
||||
'sidebar-accent': '#334155',
|
||||
'sidebar-accent-foreground': '#f8fafc',
|
||||
'sidebar-border': '#334155',
|
||||
'sidebar-ring': '#64748b'
|
||||
}
|
||||
};
|
||||
|
||||
export const PRESETS: Preset[] = [READING_ROOM, STACKS, SLATE];
|
||||
|
||||
export const DEFAULT_PRESET_ID = READING_ROOM.id;
|
||||
|
||||
export function getPreset(id: string | undefined): Preset {
|
||||
return PRESETS.find((p) => p.id === id) ?? READING_ROOM;
|
||||
}
|
||||
|
||||
/** What we persist in the cookie. Sparse by design — presets carry the rest. */
|
||||
export interface ThemeConfig {
|
||||
preset: string;
|
||||
radius?: string;
|
||||
fonts?: Partial<Record<FontTokenKey, string>>;
|
||||
light?: Palette;
|
||||
dark?: Palette;
|
||||
}
|
||||
|
||||
export const THEME_COOKIE = 'chitai-theme';
|
||||
|
||||
export function parseThemeCookie(raw: string | undefined | null): ThemeConfig {
|
||||
if (!raw) return { preset: DEFAULT_PRESET_ID };
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(raw));
|
||||
if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID };
|
||||
return { ...parsed, preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID };
|
||||
} catch {
|
||||
return { preset: DEFAULT_PRESET_ID };
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge a config over its preset to get the palette actually in force. */
|
||||
export function resolvePalette(config: ThemeConfig, mode: Mode): Palette {
|
||||
const preset = getPreset(config.preset);
|
||||
return { ...preset[mode], ...(config[mode] ?? {}) };
|
||||
}
|
||||
|
||||
export function resolveRadius(config: ThemeConfig): string {
|
||||
return config.radius ?? getPreset(config.preset).radius;
|
||||
}
|
||||
|
||||
export function resolveFonts(config: ThemeConfig): Record<FontTokenKey, string> {
|
||||
return { ...getPreset(config.preset).fonts, ...(config.fonts ?? {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a theme as CSS. Used on the server to inline the theme into the
|
||||
* document head, so the first paint is already correct.
|
||||
*
|
||||
* The doubled `:root:root` is deliberate. app.css declares the same custom
|
||||
* properties on plain `:root`, which has identical specificity, so whichever
|
||||
* stylesheet comes last would win — and in dev Vite appends app.css to the end
|
||||
* of <head>, after this tag. Doubling the selector raises specificity to
|
||||
* (0,2,0) so the stored theme wins on merit rather than on load order.
|
||||
*/
|
||||
export function themeToCss(config: ThemeConfig): string {
|
||||
const decl = (palette: Palette) =>
|
||||
Object.entries(palette)
|
||||
.map(([k, v]) => `--${k}:${v};`)
|
||||
.join('');
|
||||
|
||||
const fonts = Object.entries(resolveFonts(config))
|
||||
.map(([k, v]) => `--${k}:${v};`)
|
||||
.join('');
|
||||
|
||||
return (
|
||||
`:root:root{--radius:${resolveRadius(config)};${fonts}${decl(resolvePalette(config, 'light'))}}` +
|
||||
`:root:root.dark{${decl(resolvePalette(config, 'dark'))}}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { mode } from 'mode-watcher';
|
||||
import {
|
||||
DEFAULT_PRESET_ID,
|
||||
THEME_COOKIE,
|
||||
getPreset,
|
||||
resolveFonts,
|
||||
resolvePalette,
|
||||
resolveRadius,
|
||||
type ColorTokenKey,
|
||||
type FontTokenKey,
|
||||
type Mode,
|
||||
type ThemeConfig
|
||||
} from './presets';
|
||||
|
||||
/**
|
||||
* Live theme state.
|
||||
*
|
||||
* The server has already inlined the stored theme into the document head, so
|
||||
* this class only takes over once the user edits something: it writes the
|
||||
* changed custom properties onto <html> and persists the config to a cookie.
|
||||
*/
|
||||
export class ThemeState {
|
||||
config = $state<ThemeConfig>({ preset: DEFAULT_PRESET_ID });
|
||||
|
||||
/** Which palette the editor is currently writing to. */
|
||||
readonly mode = $derived<Mode>(mode.current === 'dark' ? 'dark' : 'light');
|
||||
|
||||
readonly preset = $derived(getPreset(this.config.preset));
|
||||
readonly palette = $derived(resolvePalette(this.config, this.mode));
|
||||
readonly radius = $derived(resolveRadius(this.config));
|
||||
readonly fonts = $derived(resolveFonts(this.config));
|
||||
|
||||
readonly isCustomised = $derived(
|
||||
Boolean(
|
||||
this.config.radius ||
|
||||
this.config.fonts ||
|
||||
Object.keys(this.config.light ?? {}).length ||
|
||||
Object.keys(this.config.dark ?? {}).length
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* True once this session has written inline custom properties onto <html>.
|
||||
* Until then the server-injected stylesheet is the only source of theme, and
|
||||
* it already covers both modes — so we touch nothing and there is no repaint.
|
||||
*/
|
||||
private touched = false;
|
||||
|
||||
constructor(config: ThemeConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
setPreset(id: string) {
|
||||
// Switching preset discards overrides; keeping them across palettes
|
||||
// produces colour combinations nobody chose.
|
||||
this.config = { preset: id };
|
||||
this.apply();
|
||||
}
|
||||
|
||||
setColor(token: ColorTokenKey | string, value: string) {
|
||||
const mode = this.mode;
|
||||
this.config = { ...this.config, [mode]: { ...(this.config[mode] ?? {}), [token]: value } };
|
||||
this.apply();
|
||||
}
|
||||
|
||||
setRadius(value: string) {
|
||||
this.config = { ...this.config, radius: value };
|
||||
this.apply();
|
||||
}
|
||||
|
||||
setFont(token: FontTokenKey, value: string) {
|
||||
this.config = { ...this.config, fonts: { ...(this.config.fonts ?? {}), [token]: value } };
|
||||
this.apply();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.config = { preset: this.config.preset };
|
||||
this.apply();
|
||||
}
|
||||
|
||||
/** Push the resolved theme onto the document and persist it. */
|
||||
apply() {
|
||||
if (!browser) return;
|
||||
this.touched = true;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--radius', this.radius);
|
||||
|
||||
for (const [key, value] of Object.entries(this.fonts)) {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
}
|
||||
|
||||
// Inline styles sit on :root and so apply in both modes. Write the
|
||||
// palette for the mode being displayed and rewrite it on mode change.
|
||||
for (const [key, value] of Object.entries(this.palette)) {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
}
|
||||
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply after a light/dark switch, since inline vars are mode-blind.
|
||||
* No-op until something has actually been edited — see `touched`.
|
||||
*/
|
||||
syncMode() {
|
||||
if (!browser || !this.touched) return;
|
||||
const palette = resolvePalette(this.config, this.mode);
|
||||
for (const [key, value] of Object.entries(palette)) {
|
||||
document.documentElement.style.setProperty(`--${key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
private persist() {
|
||||
if (!browser) return;
|
||||
const value = encodeURIComponent(JSON.stringify(this.config));
|
||||
// One year, root path, lax — same shape as the auth cookie minus httpOnly,
|
||||
// because the editor has to be able to read and rewrite it client-side.
|
||||
document.cookie = `${THEME_COOKIE}=${value};path=/;max-age=31536000;samesite=lax`;
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_KEY = Symbol('THEME');
|
||||
|
||||
export function setThemeState(config: ThemeConfig) {
|
||||
return setContext(THEME_KEY, new ThemeState(config));
|
||||
}
|
||||
|
||||
export function getThemeState() {
|
||||
return getContext<ReturnType<typeof setThemeState>>(THEME_KEY);
|
||||
}
|
||||
@@ -58,6 +58,52 @@ export function formatFileSize(bytes: number, decimals = 1, binary = true) {
|
||||
return `${formattedSize} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation for a book identifier.
|
||||
*
|
||||
* The backend stores identifiers as free-form name/value pairs, so this only
|
||||
* decides how to label and link the ones we recognise. Anything unknown falls
|
||||
* back to the stored name and renders as plain text.
|
||||
*/
|
||||
const IDENTIFIER_TYPES: Record<string, { label: string; url?: (value: string) => string }> = {
|
||||
'isbn-13': { label: 'ISBN-13', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` },
|
||||
'isbn-10': { label: 'ISBN-10', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` },
|
||||
isbn: { label: 'ISBN', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` },
|
||||
doi: { label: 'DOI', url: (v) => `https://doi.org/${encodeURI(v.trim())}` },
|
||||
asin: { label: 'ASIN', url: (v) => `https://www.amazon.com/dp/${encodeURIComponent(v.trim())}` }
|
||||
};
|
||||
|
||||
/** Order the ones worth reading first; everything else keeps its own order. */
|
||||
const IDENTIFIER_ORDER = ['isbn-13', 'isbn-10', 'isbn', 'doi', 'asin'];
|
||||
|
||||
function isbnDigits(value: string) {
|
||||
return value.replace(/[^0-9Xx]/g, '');
|
||||
}
|
||||
|
||||
function identifierKey(name: string) {
|
||||
return name.trim().toLowerCase().replace(/[\s_]+/g, '-');
|
||||
}
|
||||
|
||||
export function describeIdentifier(name: string, value: string) {
|
||||
const known = IDENTIFIER_TYPES[identifierKey(name)];
|
||||
|
||||
return {
|
||||
label: known?.label ?? name.toUpperCase(),
|
||||
href: known?.url?.(value)
|
||||
};
|
||||
}
|
||||
|
||||
export function sortIdentifiers(identifiers: Record<string, string>) {
|
||||
return Object.entries(identifiers).sort(([a], [b]) => {
|
||||
const ia = IDENTIFIER_ORDER.indexOf(identifierKey(a));
|
||||
const ib = IDENTIFIER_ORDER.indexOf(identifierKey(b));
|
||||
if (ia === -1 && ib === -1) return 0;
|
||||
if (ia === -1) return 1;
|
||||
if (ib === -1) return -1;
|
||||
return ia - ib;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFileType(filename: string) {
|
||||
const parts = filename.split('.');
|
||||
if (parts.length < 2) return 'UNKNOWN';
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 John Factotum
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Vendored foliate-js
|
||||
|
||||
Do not edit these files. They are copied verbatim from upstream by
|
||||
`frontend/scripts/vendor-foliate.sh`; local changes are lost on the next run.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Upstream | <https://github.com/readest/foliate-js> (Readest's fork of johnfactotum/foliate-js) |
|
||||
| Pinned commit | `63a2eb1fc1e4813c4e849ccdb3d4be2c54a35869` |
|
||||
| Licence | MIT — see `LICENSE` |
|
||||
|
||||
Readest's fork is used rather than upstream for its paginator work: touch/swipe
|
||||
turn handling, fixed-layout spread centring, and a malformed-XHTML fallback in
|
||||
`loadDocument`.
|
||||
|
||||
## What is here
|
||||
|
||||
Only the import closure reachable from `view.js`. Not vendored, because nothing
|
||||
reaches them: `dict.js`, `opds.js`, `footnotes.js`, `quote-image.js`,
|
||||
`uri-template.js`, `reader.js` (upstream's demo), and the build configs.
|
||||
|
||||
## pdf.js is ours, not upstream's
|
||||
|
||||
`pdf.js` in this directory is a **stub that throws**. Upstream's version imports
|
||||
`@pdfjs/pdf.min.mjs` — a bare specifier backed by a 12 MB vendored pdf.js build —
|
||||
and `view.js` reaches it through `await import('./pdf.js')`, which Rollup resolves
|
||||
at build time even though Chitai never takes that path. Chitai serves PDFs from
|
||||
`static/pdfjs/web/viewer.html` instead.
|
||||
|
||||
To enable foliate's PDF backend, add `pdf.js` and `vendor/pdfjs/` to the file list
|
||||
in the vendor script and drop the stub.
|
||||
|
||||
## Updating
|
||||
|
||||
Bump `FOLIATE_SHA` in `frontend/scripts/vendor-foliate.sh`, re-run it, review the
|
||||
diff, and smoke-test the reader — `paginator.js` is ~3800 lines of gesture and
|
||||
animation code and this fork is pushed to frequently.
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
// Read series metadata from a ComicInfo.xml entry, if present.
|
||||
// Spec: https://anansi-project.github.io/docs/comicinfo/intro
|
||||
const readComicInfoXML = async ({ entries, loadBlob }) => {
|
||||
const entry = entries.find(e => e.filename.toLowerCase() === 'comicinfo.xml')
|
||||
?? entries.find(e => e.filename.split('/').pop()?.toLowerCase() === 'comicinfo.xml')
|
||||
if (!entry) return null
|
||||
let text
|
||||
try {
|
||||
text = await (await loadBlob(entry.filename)).text()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
let doc
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(text, 'application/xml')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!doc || doc.getElementsByTagName('parsererror').length) return null
|
||||
const get = name => doc.getElementsByTagName(name).item(0)?.textContent?.trim() || undefined
|
||||
const getPositiveInteger = name => {
|
||||
const value = Number.parseInt(get(name), 10)
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
const getSubjects = () => [...new Set([get('Genre'), get('Tags')]
|
||||
.flatMap(value => value?.split(/[,;|]/).map(x => x.trim()).filter(Boolean) ?? []))]
|
||||
const getPublished = () => {
|
||||
const year = getPositiveInteger('Year')
|
||||
if (!year) return undefined
|
||||
const month = getPositiveInteger('Month')
|
||||
const day = getPositiveInteger('Day')
|
||||
const yyyy = String(year).padStart(4, '0')
|
||||
if (!month || month > 12) return yyyy
|
||||
const yyyyMm = `${yyyy}-${String(month).padStart(2, '0')}`
|
||||
if (!day || day > 31) return yyyyMm
|
||||
return `${yyyyMm}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
const subjects = getSubjects()
|
||||
return {
|
||||
title: get('Title'),
|
||||
publisher: get('Publisher'),
|
||||
language: get('LanguageISO'),
|
||||
author: get('Writer'),
|
||||
published: getPublished(),
|
||||
description: get('Summary'),
|
||||
subject: subjects.length ? subjects : undefined,
|
||||
identifier: get('Web'),
|
||||
series: get('Series'),
|
||||
seriesPosition: get('Number'),
|
||||
seriesTotal: get('Count'),
|
||||
}
|
||||
}
|
||||
|
||||
const readComicBookInfo = async ({ getComment }) => {
|
||||
let info
|
||||
try {
|
||||
info = JSON.parse(await getComment() || '')['ComicBookInfo/1.0']
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!info) return null
|
||||
const year = info.publicationYear
|
||||
const month = info.publicationMonth
|
||||
const mm = month && month >= 1 && month <= 12 ? String(month).padStart(2, '0') : null
|
||||
return {
|
||||
title: info.title,
|
||||
publisher: info.publisher,
|
||||
language: info.language || info.lang,
|
||||
author: info.credits ? info.credits.map(c => `${c.person} (${c.role})`).join(', ') : '',
|
||||
published: year && month ? `${year}-${mm}` : undefined,
|
||||
series: info.series,
|
||||
seriesPosition: info.issue == null ? undefined : String(info.issue),
|
||||
}
|
||||
}
|
||||
|
||||
export const makeComicBook = async ({ entries, loadBlob, getSize, getComment }, file) => {
|
||||
const cache = new Map()
|
||||
const urls = new Map()
|
||||
const load = async name => {
|
||||
if (cache.has(name)) return cache.get(name)
|
||||
const src = URL.createObjectURL(await loadBlob(name))
|
||||
const page = URL.createObjectURL(
|
||||
new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0"><img src="${src}"></body></html>`], { type: 'text/html' }))
|
||||
urls.set(name, [src, page])
|
||||
cache.set(name, page)
|
||||
return page
|
||||
}
|
||||
const unload = name => {
|
||||
urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url))
|
||||
urls.delete(name)
|
||||
cache.delete(name)
|
||||
}
|
||||
|
||||
const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif']
|
||||
const files = entries
|
||||
.map(entry => entry.filename)
|
||||
.filter(name => exts.some(ext => name.endsWith(ext)))
|
||||
.sort()
|
||||
if (!files.length) throw new Error('No supported image files in archive')
|
||||
|
||||
const book = {}
|
||||
// Prefer ComicInfo.xml (Anansi standard) over ComicBookInfo (JSON in zip comment).
|
||||
// Fields missing from the preferred source fall through to the secondary one.
|
||||
const xml = await readComicInfoXML({ entries, loadBlob })
|
||||
const cbi = await readComicBookInfo({ getComment })
|
||||
const merged = { ...(cbi || {}), ...(xml || {}) }
|
||||
book.metadata = {
|
||||
title: merged.title || file.name,
|
||||
publisher: merged.publisher,
|
||||
language: merged.language,
|
||||
author: merged.author,
|
||||
published: merged.published,
|
||||
description: merged.description,
|
||||
subject: merged.subject,
|
||||
identifier: merged.identifier,
|
||||
}
|
||||
if (merged.series) {
|
||||
const series = { name: merged.series }
|
||||
if (merged.seriesPosition) series.position = merged.seriesPosition
|
||||
if (merged.seriesTotal) series.total = merged.seriesTotal
|
||||
book.metadata.belongsTo = { series }
|
||||
}
|
||||
book.getCover = () => loadBlob(files[0])
|
||||
book.sections = files.map(name => ({
|
||||
id: name,
|
||||
load: () => load(name),
|
||||
unload: () => unload(name),
|
||||
size: getSize(name),
|
||||
}))
|
||||
book.toc = files.map(name => ({ label: name, href: name }))
|
||||
book.rendition = { layout: 'pre-paginated' }
|
||||
book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) })
|
||||
book.splitTOCHref = href => [href, null]
|
||||
book.getTOCFragment = doc => doc.documentElement
|
||||
book.destroy = () => {
|
||||
for (const arr of urls.values())
|
||||
for (const url of arr) URL.revokeObjectURL(url)
|
||||
}
|
||||
return book
|
||||
}
|
||||
+1345
File diff suppressed because it is too large
Load Diff
+369
@@ -0,0 +1,369 @@
|
||||
const findIndices = (arr, f) => arr
|
||||
.map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null)
|
||||
const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) =>
|
||||
({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs
|
||||
const concatArrays = (a, b) =>
|
||||
a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1))
|
||||
|
||||
const isNumber = /\d/
|
||||
export const isCFI = /^epubcfi\((.*)\)$/
|
||||
const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&')
|
||||
|
||||
const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})`
|
||||
const unwrap = x => x.match(isCFI)?.[1] ?? x
|
||||
const lift = f => (...xs) =>
|
||||
`epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})`
|
||||
export const joinIndir = lift((...xs) => xs.join('!'))
|
||||
|
||||
const tokenizer = str => {
|
||||
const tokens = []
|
||||
let state, escape, value = ''
|
||||
const push = x => (tokens.push(x), state = null, value = '')
|
||||
const cat = x => (value += x, escape = false)
|
||||
for (const char of Array.from(str.trim()).concat('')) {
|
||||
if (char === '^' && !escape) {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if (state === '!') push(['!'])
|
||||
else if (state === ',') push([','])
|
||||
else if (state === '/' || state === ':') {
|
||||
if (isNumber.test(char)) {
|
||||
cat(char)
|
||||
continue
|
||||
} else push([state, parseInt(value)])
|
||||
} else if (state === '~') {
|
||||
if (isNumber.test(char) || char === '.') {
|
||||
cat(char)
|
||||
continue
|
||||
} else push(['~', parseFloat(value)])
|
||||
} else if (state === '@') {
|
||||
if (char === ':') {
|
||||
push(['@', parseFloat(value)])
|
||||
state = '@'
|
||||
continue
|
||||
}
|
||||
if (isNumber.test(char) || char === '.') {
|
||||
cat(char)
|
||||
continue
|
||||
} else push(['@', parseFloat(value)])
|
||||
} else if (state === '[') {
|
||||
if (char === ';' && !escape) {
|
||||
push(['[', value])
|
||||
state = ';'
|
||||
} else if (char === ',' && !escape) {
|
||||
push(['[', value])
|
||||
state = '['
|
||||
} else if (char === ']' && !escape) push(['[', value])
|
||||
else cat(char)
|
||||
continue
|
||||
} else if (state?.startsWith(';')) {
|
||||
if (char === '=' && !escape) {
|
||||
state = `;${value}`
|
||||
value = ''
|
||||
} else if (char === ';' && !escape) {
|
||||
push([state, value])
|
||||
state = ';'
|
||||
} else if (char === ']' && !escape) push([state, value])
|
||||
else cat(char)
|
||||
continue
|
||||
}
|
||||
if (char === '/' || char === ':' || char === '~' || char === '@'
|
||||
|| char === '[' || char === '!' || char === ',') state = char
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x)
|
||||
|
||||
const parser = tokens => {
|
||||
const parts = []
|
||||
let state
|
||||
for (const [type, val] of tokens) {
|
||||
if (type === '/') parts.push({ index: val })
|
||||
else {
|
||||
const last = parts[parts.length - 1]
|
||||
if (type === ':') last.offset = val
|
||||
else if (type === '~') last.temporal = val
|
||||
else if (type === '@') last.spatial = (last.spatial ?? []).concat(val)
|
||||
else if (type === ';s') last.side = val
|
||||
else if (type === '[') {
|
||||
if (state === '/' && val) last.id = val
|
||||
else {
|
||||
last.text = (last.text ?? []).concat(val)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
state = type
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// split at step indirections, then parse each part
|
||||
const parserIndir = tokens =>
|
||||
splitAt(tokens, findTokens(tokens, '!')).map(parser)
|
||||
|
||||
export const parse = cfi => {
|
||||
const tokens = tokenizer(unwrap(cfi))
|
||||
const commas = findTokens(tokens, ',')
|
||||
if (!commas.length) return parserIndir(tokens)
|
||||
const [parent, start, end] = splitAt(tokens, commas).map(parserIndir)
|
||||
return { parent, start, end }
|
||||
}
|
||||
|
||||
const partToString = ({ index, id, offset, temporal, spatial, text, side }) => {
|
||||
const param = side ? `;s=${side}` : ''
|
||||
return `/${index}`
|
||||
+ (id ? `[${escapeCFI(id)}${param}]` : '')
|
||||
// "CFI expressions [..] SHOULD include an explicit character offset"
|
||||
+ (offset != null && index % 2 ? `:${offset}` : '')
|
||||
+ (temporal ? `~${temporal}` : '')
|
||||
+ (spatial ? `@${spatial.join(':')}` : '')
|
||||
+ (text || (!id && side) ? '['
|
||||
+ (text?.map(escapeCFI)?.join(',') ?? '')
|
||||
+ param + ']' : '')
|
||||
}
|
||||
|
||||
const toInnerString = parsed => parsed.parent
|
||||
? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',')
|
||||
: parsed.map(parts => parts.map(partToString).join('')).join('!')
|
||||
|
||||
const toString = parsed => wrap(toInnerString(parsed))
|
||||
|
||||
export const collapse = (x, toEnd) => typeof x === 'string'
|
||||
? toString(collapse(parse(x), toEnd))
|
||||
: x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x
|
||||
|
||||
// create range CFI from two CFIs
|
||||
const buildRange = (from, to) => {
|
||||
if (typeof from === 'string') from = parse(from)
|
||||
if (typeof to === 'string') to = parse(to)
|
||||
from = collapse(from)
|
||||
to = collapse(to, true)
|
||||
// ranges across multiple documents are not allowed; handle local paths only
|
||||
const localFrom = from[from.length - 1], localTo = to[to.length - 1]
|
||||
const localParent = [], localStart = [], localEnd = []
|
||||
let pushToParent = true
|
||||
const len = Math.max(localFrom.length, localTo.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const a = localFrom[i], b = localTo[i]
|
||||
pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset
|
||||
if (pushToParent) localParent.push(a)
|
||||
else {
|
||||
if (a) localStart.push(a)
|
||||
if (b) localEnd.push(b)
|
||||
}
|
||||
}
|
||||
// copy non-local paths from `from`
|
||||
const parent = from.slice(0, -1).concat([localParent])
|
||||
return toString({ parent, start: [localStart], end: [localEnd] })
|
||||
}
|
||||
|
||||
export const compare = (a, b) => {
|
||||
if (typeof a === 'string') a = parse(a)
|
||||
if (typeof b === 'string') b = parse(b)
|
||||
if (a.start || b.start) return compare(collapse(a), collapse(b))
|
||||
|| compare(collapse(a, true), collapse(b, true))
|
||||
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const p = a[i] ?? [], q = b[i] ?? []
|
||||
const maxIndex = Math.max(p.length, q.length) - 1
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
const x = p[i], y = q[i]
|
||||
if (!x) return -1
|
||||
if (!y) return 1
|
||||
if (x.index > y.index) return 1
|
||||
if (x.index < y.index) return -1
|
||||
if (i === maxIndex) {
|
||||
// TODO: compare temporal & spatial offsets
|
||||
if (x.offset > y.offset) return 1
|
||||
if (x.offset < y.offset) return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const isTextNode = node => node?.nodeType === 3 || node?.nodeType === 4
|
||||
const isElementNode = node => node?.nodeType === 1
|
||||
// cfi-inert: the node AND its subtree are invisible to CFI (e.g. injected a11y
|
||||
// skip-links). cfi-skip: only the node itself is invisible — its children are
|
||||
// hoisted into its parent, so they keep the indices they'd have without the
|
||||
// wrapper (e.g. a layout-only <div> that wraps a table/equation for scrolling).
|
||||
const isInertNode = (node) => node.hasAttribute?.('cfi-inert')
|
||||
const isSkipNode = (node) => node.hasAttribute?.('cfi-skip')
|
||||
|
||||
// CFI-relevant children: text + elements, with cfi-inert nodes removed and
|
||||
// cfi-skip wrappers spliced out (their own children hoisted in place, recursively).
|
||||
const rawChildNodes = (node) => Array.from(node.childNodes)
|
||||
// "content other than element and character data is ignored"
|
||||
.filter(node => isTextNode(node) || isElementNode(node))
|
||||
.filter(node => !isInertNode(node))
|
||||
.flatMap(node => isSkipNode(node) ? rawChildNodes(node) : [node])
|
||||
|
||||
const getChildNodes = (node, filter) => {
|
||||
const nodes = rawChildNodes(node)
|
||||
return filter ? nodes.map(node => {
|
||||
const accept = filter(node)
|
||||
if (accept === NodeFilter.FILTER_REJECT) return null
|
||||
else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter)
|
||||
else return node
|
||||
}).flat().filter(x => x) : nodes
|
||||
}
|
||||
|
||||
// child nodes are organized such that the result is always
|
||||
// [element, text, element, text, ..., element],
|
||||
// regardless of the actual structure in the document;
|
||||
// so multiple text nodes need to be combined, and nonexistent ones counted;
|
||||
// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec
|
||||
const indexChildNodes = (node, filter) => {
|
||||
const nodes = getChildNodes(node, filter)
|
||||
.reduce((arr, node) => {
|
||||
let last = arr[arr.length - 1]
|
||||
if (!last) arr.push(node)
|
||||
// "there is one chunk between each pair of child elements"
|
||||
else if (isTextNode(node)) {
|
||||
if (Array.isArray(last)) last.push(node)
|
||||
else if (isTextNode(last)) arr[arr.length - 1] = [last, node]
|
||||
else arr.push(node)
|
||||
} else {
|
||||
if (isElementNode(last)) arr.push(null, node)
|
||||
else arr.push(node)
|
||||
}
|
||||
return arr
|
||||
}, [])
|
||||
// "the first chunk is located before the first child element"
|
||||
if (isElementNode(nodes[0])) nodes.unshift('first')
|
||||
// "the last chunk is located after the last child element"
|
||||
if (isElementNode(nodes[nodes.length - 1])) nodes.push('last')
|
||||
// "'virtual' elements"
|
||||
nodes.unshift('before') // "0 is a valid index"
|
||||
nodes.push('after') // "n+2 is a valid index"
|
||||
return nodes
|
||||
}
|
||||
|
||||
const partsToNode = (node, parts, filter) => {
|
||||
const { id } = parts[parts.length - 1]
|
||||
if (id) {
|
||||
const el = node.ownerDocument.getElementById(id)
|
||||
if (el) return { node: el, offset: 0 }
|
||||
}
|
||||
for (const { index } of parts) {
|
||||
const newNode = node ? indexChildNodes(node, filter)[index] : null
|
||||
// handle non-existent nodes
|
||||
if (newNode === 'first') return { node: node.firstChild ?? node }
|
||||
if (newNode === 'last') return { node: node.lastChild ?? node }
|
||||
if (newNode === 'before') return { node, before: true }
|
||||
if (newNode === 'after') return { node, after: true }
|
||||
node = newNode
|
||||
}
|
||||
const { offset } = parts[parts.length - 1]
|
||||
if (!Array.isArray(node)) return { node, offset }
|
||||
// get underlying text node and offset from the chunk
|
||||
let sum = 0
|
||||
for (const n of node) {
|
||||
const { length } = n.nodeValue
|
||||
if (sum + length >= offset) return { node: n, offset: offset - sum }
|
||||
sum += length
|
||||
}
|
||||
}
|
||||
|
||||
const nodeToParts = (node, offset, filter) => {
|
||||
const { id } = node
|
||||
// A cfi-skip wrapper is invisible to CFI, so index this node within the
|
||||
// wrapper's nearest non-skip ancestor — where rawChildNodes has hoisted it —
|
||||
// rather than within the wrapper. Otherwise its index would be computed
|
||||
// relative to the wrapper and not match the same node without the wrapper.
|
||||
let parentNode = node.parentNode
|
||||
while (parentNode && isSkipNode(parentNode)) parentNode = parentNode.parentNode
|
||||
const indexed = indexChildNodes(parentNode, filter)
|
||||
const index = indexed.findIndex(x =>
|
||||
Array.isArray(x) ? x.some(x => x === node) : x === node)
|
||||
// adjust offset as if merging the text nodes in the chunk
|
||||
const chunk = indexed[index]
|
||||
if (Array.isArray(chunk)) {
|
||||
let sum = 0
|
||||
for (const x of chunk) {
|
||||
if (x === node) {
|
||||
sum += offset
|
||||
break
|
||||
} else sum += x.nodeValue.length
|
||||
}
|
||||
offset = sum
|
||||
}
|
||||
const part = { id, index, offset }
|
||||
return (parentNode !== node.ownerDocument.documentElement
|
||||
? nodeToParts(parentNode, null, filter).concat(part) : [part])
|
||||
// remove ignored nodes
|
||||
.filter(x => x.index !== -1)
|
||||
}
|
||||
|
||||
export const fromRange = (range, filter) => {
|
||||
const { startContainer, startOffset, endContainer, endOffset } = range
|
||||
const start = nodeToParts(startContainer, startOffset, filter)
|
||||
if (range.collapsed) return toString([start])
|
||||
const end = nodeToParts(endContainer, endOffset, filter)
|
||||
return buildRange([start], [end])
|
||||
}
|
||||
|
||||
export const toRange = (doc, parts, filter) => {
|
||||
try {
|
||||
const startParts = collapse(parts)
|
||||
const endParts = collapse(parts, true)
|
||||
|
||||
const root = doc.documentElement
|
||||
const start = partsToNode(root, startParts[0], filter)
|
||||
const end = partsToNode(root, endParts[0], filter)
|
||||
|
||||
if (!start?.node || !end?.node) return null
|
||||
|
||||
const range = doc.createRange()
|
||||
|
||||
if (start.before) range.setStartBefore(start.node)
|
||||
else if (start.after) range.setStartAfter(start.node)
|
||||
else range.setStart(start.node, start.offset)
|
||||
|
||||
if (end.before) range.setEndBefore(end.node)
|
||||
else if (end.after) range.setEndAfter(end.node)
|
||||
else range.setEnd(end.node, end.offset)
|
||||
return range
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// faster way of getting CFIs for sorted elements in a single parent
|
||||
export const fromElements = elements => {
|
||||
const results = []
|
||||
const { parentNode } = elements[0]
|
||||
const parts = nodeToParts(parentNode)
|
||||
for (const [index, node] of indexChildNodes(parentNode).entries()) {
|
||||
const el = elements[results.length]
|
||||
if (node === el)
|
||||
results.push(toString([parts.concat({ id: el.id, index })]))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export const toElement = (doc, parts) =>
|
||||
partsToNode(doc.documentElement, collapse(parts)).node
|
||||
|
||||
// turn indices into standard CFIs when you don't have an actual package document
|
||||
export const fake = {
|
||||
fromIndex: index => wrap(`/6/${(index + 1) * 2}`),
|
||||
toIndex: parts => parts?.at(-1).index / 2 - 1,
|
||||
}
|
||||
|
||||
// get CFI from Calibre bookmarks
|
||||
// see https://github.com/johnfactotum/foliate/issues/849
|
||||
export const fromCalibrePos = pos => {
|
||||
const [parts] = parse(pos)
|
||||
const item = parts.shift()
|
||||
parts.shift()
|
||||
return toString([[{ index: 6 }, item], parts])
|
||||
}
|
||||
export const fromCalibreHighlight = ({ spine_index, start_cfi, end_cfi }) => {
|
||||
const pre = fake.fromIndex(spine_index) + '!'
|
||||
return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2))
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
const normalizeWhitespace = str => str ? str
|
||||
.replace(/[\t\n\f\r ]+/g, ' ')
|
||||
.replace(/^[\t\n\f\r ]+/, '')
|
||||
.replace(/[\t\n\f\r ]+$/, '') : ''
|
||||
const getElementText = el => normalizeWhitespace(el?.textContent)
|
||||
|
||||
const NS = {
|
||||
XLINK: 'http://www.w3.org/1999/xlink',
|
||||
EPUB: 'http://www.idpf.org/2007/ops',
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
XML: 'application/xml',
|
||||
XHTML: 'application/xhtml+xml',
|
||||
}
|
||||
|
||||
const STYLE = {
|
||||
'strong': ['strong', 'self'],
|
||||
'emphasis': ['em', 'self'],
|
||||
'style': ['span', 'self'],
|
||||
'a': 'anchor',
|
||||
'strikethrough': ['s', 'self'],
|
||||
'sub': ['sub', 'self'],
|
||||
'sup': ['sup', 'self'],
|
||||
'code': ['code', 'self'],
|
||||
'image': 'image',
|
||||
}
|
||||
|
||||
const TABLE = {
|
||||
'tr': ['tr', {
|
||||
'th': ['th', STYLE, ['colspan', 'rowspan', 'align', 'valign']],
|
||||
'td': ['td', STYLE, ['colspan', 'rowspan', 'align', 'valign']],
|
||||
}, ['align']],
|
||||
}
|
||||
|
||||
const POEM = {
|
||||
'epigraph': ['blockquote'],
|
||||
'subtitle': ['h2', STYLE],
|
||||
'text-author': ['p', STYLE],
|
||||
'date': ['p', STYLE],
|
||||
'stanza': ['div', 'self'],
|
||||
'v': ['div', STYLE],
|
||||
}
|
||||
|
||||
const SECTION = {
|
||||
'title': ['header', {
|
||||
'p': ['h1', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'epigraph': ['blockquote', 'self'],
|
||||
'image': 'image',
|
||||
'annotation': ['aside'],
|
||||
'section': ['section', 'self'],
|
||||
'p': ['p', STYLE],
|
||||
'poem': ['blockquote', POEM],
|
||||
'subtitle': ['h2', STYLE],
|
||||
'cite': ['blockquote', 'self'],
|
||||
'empty-line': ['br'],
|
||||
'table': ['table', TABLE],
|
||||
'text-author': ['p', STYLE],
|
||||
}
|
||||
POEM['epigraph'].push(SECTION)
|
||||
|
||||
const BODY = {
|
||||
'image': 'image',
|
||||
'title': ['section', {
|
||||
'p': ['h1', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'epigraph': ['section', SECTION],
|
||||
'section': ['section', SECTION],
|
||||
}
|
||||
|
||||
class FB2Converter {
|
||||
constructor(fb2) {
|
||||
this.fb2 = fb2
|
||||
this.doc = document.implementation.createDocument(NS.XHTML, 'html')
|
||||
// use this instead of `getElementById` to allow images like
|
||||
// `<image l:href="#img1.jpg" id="img1.jpg" />`
|
||||
this.bins = new Map(Array.from(this.fb2.getElementsByTagName('binary'),
|
||||
el => [el.id, el]))
|
||||
}
|
||||
getImageSrc(el) {
|
||||
const href = el.getAttributeNS(NS.XLINK, 'href')
|
||||
if (!href) return 'data:,'
|
||||
const [, id] = href.split('#')
|
||||
if (!id) return href
|
||||
const bin = this.bins.get(id)
|
||||
return bin
|
||||
? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}`
|
||||
: href
|
||||
}
|
||||
image(node) {
|
||||
const el = this.doc.createElement('img')
|
||||
el.alt = node.getAttribute('alt')
|
||||
el.title = node.getAttribute('title')
|
||||
el.setAttribute('src', this.getImageSrc(node))
|
||||
return el
|
||||
}
|
||||
anchor(node) {
|
||||
const el = this.convert(node, { 'a': ['a', STYLE] })
|
||||
el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href'))
|
||||
if (node.getAttribute('type') === 'note')
|
||||
el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref')
|
||||
return el
|
||||
}
|
||||
convert(node, def) {
|
||||
// not an element; return text content
|
||||
if (node.nodeType === 3) return this.doc.createTextNode(node.textContent)
|
||||
if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent)
|
||||
if (node.nodeType === 8) return this.doc.createComment(node.textContent)
|
||||
|
||||
const d = def?.[node.nodeName]
|
||||
if (!d) return null
|
||||
if (typeof d === 'string') return this[d](node)
|
||||
|
||||
const [name, opts, attrs] = d
|
||||
const el = this.doc.createElement(name)
|
||||
|
||||
// copy the ID, and set class name from original element name
|
||||
if (node.id) el.id = node.id
|
||||
el.classList.add(node.nodeName)
|
||||
|
||||
// copy attributes
|
||||
if (Array.isArray(attrs)) for (const attr of attrs) {
|
||||
const value = node.getAttribute(attr)
|
||||
if (value) el.setAttribute(attr, value)
|
||||
}
|
||||
|
||||
// process child elements recursively
|
||||
const childDef = opts === 'self' ? def : opts
|
||||
let child = node.firstChild
|
||||
while (child) {
|
||||
const childEl = this.convert(child, childDef)
|
||||
if (childEl) el.append(childEl)
|
||||
child = child.nextSibling
|
||||
}
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
const parseXML = async blob => {
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const str = new TextDecoder('utf-8').decode(buffer)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(str, MIME.XML)
|
||||
const encoding = doc.xmlEncoding
|
||||
// `Document.xmlEncoding` is deprecated, and already removed in Firefox
|
||||
// so parse the XML declaration manually
|
||||
|| str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1]
|
||||
if (encoding && encoding.toLowerCase() !== 'utf-8') {
|
||||
const str = new TextDecoder(encoding).decode(buffer)
|
||||
return parser.parseFromString(str, MIME.XML)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
const style = URL.createObjectURL(new Blob([`
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
body > img, section > img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
.title h1 {
|
||||
text-align: center;
|
||||
}
|
||||
body > section > .title, body.notesBodyType > .title {
|
||||
margin: 3em 0;
|
||||
}
|
||||
body.notesBodyType > section .title h1 {
|
||||
text-align: start;
|
||||
}
|
||||
body.notesBodyType > section .title {
|
||||
margin: 1em 0;
|
||||
}
|
||||
p {
|
||||
text-indent: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
:not(p) + p, p:first-child {
|
||||
text-indent: 0;
|
||||
}
|
||||
.stanza {
|
||||
text-indent: 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.text-author, .date {
|
||||
text-align: end;
|
||||
}
|
||||
.text-author:before {
|
||||
content: "—";
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td, th {
|
||||
padding: .25em;
|
||||
}
|
||||
a[epub|type~="noteref"] {
|
||||
font-size: .75em;
|
||||
vertical-align: super;
|
||||
}
|
||||
body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph {
|
||||
margin: 3em 0;
|
||||
}
|
||||
`], { type: 'text/css' }))
|
||||
|
||||
const template = html => `<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><link href="${style}" rel="stylesheet" type="text/css"/></head>
|
||||
<body>${html}</body>
|
||||
</html>`
|
||||
|
||||
// name of custom ID attribute for TOC items
|
||||
const dataID = 'data-foliate-id'
|
||||
|
||||
export const makeFB2 = async blob => {
|
||||
const book = {}
|
||||
const doc = await parseXML(blob)
|
||||
const converter = new FB2Converter(doc)
|
||||
|
||||
const $ = x => doc.querySelector(x)
|
||||
const $$ = x => [...doc.querySelectorAll(x)]
|
||||
const getPerson = el => {
|
||||
const nick = getElementText(el.querySelector('nickname'))
|
||||
if (nick) return nick
|
||||
const first = getElementText(el.querySelector('first-name'))
|
||||
const middle = getElementText(el.querySelector('middle-name'))
|
||||
const last = getElementText(el.querySelector('last-name'))
|
||||
const name = [first, middle, last].filter(x => x).join(' ')
|
||||
const sortAs = last
|
||||
? [last, [first, middle].filter(x => x).join(' ')].join(', ')
|
||||
: null
|
||||
return { name, sortAs }
|
||||
}
|
||||
const getDate = el => el?.getAttribute('value') ?? getElementText(el)
|
||||
const annotation = $('title-info annotation')
|
||||
// FB2 stores series info as `<sequence name="…" number="…"/>` in title-info
|
||||
const series = $$('title-info > sequence')
|
||||
.map(el => ({
|
||||
name: normalizeWhitespace(el.getAttribute('name')),
|
||||
position: el.getAttribute('number') || undefined,
|
||||
}))
|
||||
.filter(x => x.name)
|
||||
book.metadata = {
|
||||
title: getElementText($('title-info book-title')),
|
||||
identifier: getElementText($('document-info id')),
|
||||
language: getElementText($('title-info lang')),
|
||||
author: $$('title-info author').map(getPerson),
|
||||
translator: $$('title-info translator').map(getPerson),
|
||||
contributor: $$('document-info author').map(getPerson)
|
||||
// techincially the program probably shouldn't get the `bkp` role
|
||||
// but it has been so used by calibre, so ¯\_(ツ)_/¯
|
||||
.concat($$('document-info program-used').map(getElementText))
|
||||
.map(x => Object.assign(typeof x === 'string' ? { name: x } : x,
|
||||
{ role: 'bkp' })),
|
||||
belongsTo: series.length ? { series } : undefined,
|
||||
publisher: getElementText($('publish-info publisher')),
|
||||
published: getDate($('title-info date')),
|
||||
modified: getDate($('document-info date')),
|
||||
description: annotation ? converter.convert(annotation,
|
||||
{ annotation: ['div', SECTION] }).innerHTML : null,
|
||||
subject: $$('title-info genre').map(getElementText),
|
||||
}
|
||||
if ($('coverpage image')) {
|
||||
const src = converter.getImageSrc($('coverpage image'))
|
||||
book.getCover = () => fetch(src).then(res => res.blob())
|
||||
} else book.getCover = () => null
|
||||
|
||||
// get convert each body
|
||||
const bodyData = Array.from(doc.querySelectorAll('body'), body => {
|
||||
const converted = converter.convert(body, { body: ['body', BODY] })
|
||||
return [Array.from(converted.children, el => {
|
||||
// get list of IDs in the section
|
||||
const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id)
|
||||
return { el, ids }
|
||||
}), converted]
|
||||
})
|
||||
|
||||
const urls = []
|
||||
const sectionData = bodyData[0][0]
|
||||
// make a separate section for each section in the first body
|
||||
.map(({ el, ids }, id) => {
|
||||
// set up titles for TOC
|
||||
const titles = Array.from(
|
||||
el.querySelectorAll(':scope > section > .title'),
|
||||
(el, index) => {
|
||||
el.setAttribute(dataID, index)
|
||||
const section = el.closest('section')
|
||||
const size = new TextEncoder().encode(section.innerHTML).length
|
||||
- Array.from(section.querySelectorAll('[src]'))
|
||||
.reduce((sum, el) => sum + (el.getAttribute('src')?.length ?? 0), 0)
|
||||
return { title: getElementText(el), index, size, href: `${id}#${index}` }
|
||||
})
|
||||
return { ids, titles, el }
|
||||
})
|
||||
// for additional bodies, only make one section for each body
|
||||
.concat(bodyData.slice(1).map(([sections, body]) => {
|
||||
const ids = sections.map(s => s.ids).flat()
|
||||
body.classList.add('notesBodyType')
|
||||
return { ids, el: body, linear: 'no' }
|
||||
}))
|
||||
.map(({ ids, titles, el, linear }) => {
|
||||
const str = template(el.outerHTML)
|
||||
const blob = new Blob([str], { type: MIME.XHTML })
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.push(url)
|
||||
const title = normalizeWhitespace(
|
||||
el.querySelector('.title, .subtitle, p')?.textContent
|
||||
?? (el.classList.contains('title') ? el.textContent : ''))
|
||||
return {
|
||||
ids, title, titles, load: () => url,
|
||||
createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML),
|
||||
// doo't count image data as it'd skew the size too much
|
||||
size: blob.size - Array.from(el.querySelectorAll('[src]'),
|
||||
el => el.getAttribute('src')?.length ?? 0)
|
||||
.reduce((a, b) => a + b, 0),
|
||||
linear,
|
||||
}
|
||||
})
|
||||
|
||||
const idMap = new Map()
|
||||
book.sections = sectionData.map((section, index) => {
|
||||
const { ids, load, createDocument, size, linear, titles } = section
|
||||
for (const id of ids) if (id) idMap.set(id, index)
|
||||
return { id: index, load, createDocument, size, linear, subitems: titles }
|
||||
})
|
||||
|
||||
book.toc = sectionData.map(({ title, titles }, index) => {
|
||||
const id = index.toString()
|
||||
return {
|
||||
label: title,
|
||||
href: id,
|
||||
subitems: titles?.length ? titles.map(({ title, index }) => ({
|
||||
label: title,
|
||||
href: `${id}#${index}`,
|
||||
})) : null,
|
||||
}
|
||||
}).filter(item => item)
|
||||
|
||||
book.resolveHref = href => {
|
||||
const [a, b] = href.split('#')
|
||||
return a
|
||||
// the link is from the TOC
|
||||
? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) }
|
||||
// link from within the page
|
||||
: { index: idMap.get(b), anchor: doc => doc.getElementById(b) }
|
||||
}
|
||||
book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? []
|
||||
book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`)
|
||||
|
||||
book.destroy = () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url)
|
||||
}
|
||||
return book
|
||||
}
|
||||
+1815
File diff suppressed because it is too large
Load Diff
+1279
File diff suppressed because it is too large
Load Diff
+437
@@ -0,0 +1,437 @@
|
||||
const createSVGElement = tag =>
|
||||
document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||
|
||||
let overlayerCounter = 0
|
||||
|
||||
export class Overlayer {
|
||||
#svg = createSVGElement('svg')
|
||||
#map = new Map()
|
||||
#doc = null
|
||||
#clipPath = null
|
||||
#clipPathPath = null
|
||||
#clipPathId
|
||||
|
||||
constructor(doc) {
|
||||
this.#doc = doc
|
||||
this.#clipPathId = `foliate-loupe-clip-${overlayerCounter++}`
|
||||
Object.assign(this.#svg.style, {
|
||||
position: 'absolute', top: '0', left: '0',
|
||||
width: '100%', height: '100%',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
|
||||
// Create a clipPath to cut a hole for the loupe.
|
||||
// We use clip-rule="evenodd" with a large outer rect and inner circle
|
||||
// to create the hole effect efficiently without mask compositing.
|
||||
const defs = createSVGElement('defs')
|
||||
this.#clipPath = createSVGElement('clipPath')
|
||||
this.#clipPath.setAttribute('id', this.#clipPathId)
|
||||
this.#clipPath.setAttribute('clipPathUnits', 'userSpaceOnUse')
|
||||
|
||||
this.#clipPathPath = createSVGElement('path')
|
||||
this.#clipPathPath.setAttribute('clip-rule', 'evenodd')
|
||||
this.#clipPathPath.setAttribute('fill-rule', 'evenodd') // for older renderers
|
||||
|
||||
this.#clipPath.append(this.#clipPathPath)
|
||||
defs.append(this.#clipPath)
|
||||
this.#svg.append(defs)
|
||||
}
|
||||
|
||||
setHole(cx, cy, w, h, r) {
|
||||
// Define a path with a large outer rect and a capsule-shaped hole.
|
||||
// The capsule is a rounded rectangle (stadium shape) centred at (cx, cy).
|
||||
const outer = 'M -2000000 -2000000 H 4000000 V 4000000 H -2000000 Z'
|
||||
const hw = w / 2, hh = h / 2
|
||||
const cr = Math.min(r, hw, hh) // clamp corner radius
|
||||
const inner = `M ${cx - hw + cr} ${cy - hh}`
|
||||
+ ` H ${cx + hw - cr}`
|
||||
+ ` A ${cr} ${cr} 0 0 1 ${cx + hw} ${cy - hh + cr}`
|
||||
+ ` V ${cy + hh - cr}`
|
||||
+ ` A ${cr} ${cr} 0 0 1 ${cx + hw - cr} ${cy + hh}`
|
||||
+ ` H ${cx - hw + cr}`
|
||||
+ ` A ${cr} ${cr} 0 0 1 ${cx - hw} ${cy + hh - cr}`
|
||||
+ ` V ${cy - hh + cr}`
|
||||
+ ` A ${cr} ${cr} 0 0 1 ${cx - hw + cr} ${cy - hh} Z`
|
||||
this.#clipPathPath.setAttribute('d', `${outer} ${inner}`)
|
||||
|
||||
this.#svg.setAttribute('clip-path', `url(#${this.#clipPathId})`)
|
||||
this.#svg.style.webkitClipPath = `url(#${this.#clipPathId})`
|
||||
}
|
||||
|
||||
clearHole() {
|
||||
this.#svg.removeAttribute('clip-path')
|
||||
this.#svg.style.webkitClipPath = ''
|
||||
this.#clipPathPath.removeAttribute('d')
|
||||
}
|
||||
|
||||
get element() {
|
||||
return this.#svg
|
||||
}
|
||||
get #zoom() {
|
||||
// Safari does not zoom the client rects, while Chrome, Edge and Firefox does
|
||||
if (/^((?!chrome|android).)*AppleWebKit/i.test(navigator.userAgent) && !window.chrome) {
|
||||
return window.getComputedStyle(this.#doc.body).zoom || 1.0
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
// Split a range into per-text-node sub-ranges (plus replaced elements
|
||||
// like images), so `getClientRects()` only ever returns line-level boxes.
|
||||
// Collecting rects on the whole range would also include the border boxes
|
||||
// of fully contained block elements, over-highlighting blank space.
|
||||
#splitRange(range) {
|
||||
const ancestor = range.commonAncestorContainer
|
||||
if (ancestor.nodeType !== Node.ELEMENT_NODE
|
||||
&& ancestor.nodeType !== Node.DOCUMENT_NODE) return [range]
|
||||
const doc = ancestor.ownerDocument ?? ancestor
|
||||
const walker = doc.createTreeWalker(ancestor,
|
||||
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
|
||||
acceptNode: node => {
|
||||
if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT
|
||||
// Ruby annotations sit on their own line above (or beside)
|
||||
// the base, so their rects would draw a second detached box
|
||||
// over the furigana. Never paint them — not the book's own
|
||||
// ruby, not injected glosses.
|
||||
const el = node.nodeType === Node.TEXT_NODE
|
||||
? node.parentElement : node
|
||||
if (el?.closest?.('rt, rp, rtc, [cfi-inert]'))
|
||||
return NodeFilter.FILTER_REJECT
|
||||
if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT
|
||||
return node.matches?.('img, svg')
|
||||
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
|
||||
},
|
||||
})
|
||||
const splitRanges = []
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const subRange = doc.createRange()
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
subRange.selectNodeContents(node)
|
||||
if (subRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) {
|
||||
subRange.setStart(range.startContainer, range.startOffset)
|
||||
}
|
||||
if (subRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) {
|
||||
subRange.setEnd(range.endContainer, range.endOffset)
|
||||
}
|
||||
} else subRange.selectNode(node)
|
||||
splitRanges.push(subRange)
|
||||
}
|
||||
return splitRanges.length === 0 ? [range] : splitRanges
|
||||
}
|
||||
#getRects(range) {
|
||||
const zoom = this.#zoom
|
||||
const rects = []
|
||||
for (const subRange of this.#splitRange(range)) {
|
||||
for (const rect of subRange.getClientRects()) {
|
||||
rects.push({
|
||||
left: rect.left * zoom,
|
||||
top: rect.top * zoom,
|
||||
right: rect.right * zoom,
|
||||
bottom: rect.bottom * zoom,
|
||||
width: rect.width * zoom,
|
||||
height: rect.height * zoom,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rects
|
||||
}
|
||||
add(key, range, draw, options) {
|
||||
if (this.#map.has(key)) this.remove(key)
|
||||
if (typeof range === 'function') range = range(this.#svg.getRootNode())
|
||||
const rects = this.#getRects(range)
|
||||
const element = draw(rects, options)
|
||||
this.#svg.append(element)
|
||||
this.#map.set(key, { range, draw, options, element, rects })
|
||||
}
|
||||
remove(key) {
|
||||
if (!this.#map.has(key)) return
|
||||
this.#svg.removeChild(this.#map.get(key).element)
|
||||
this.#map.delete(key)
|
||||
}
|
||||
redraw() {
|
||||
for (const obj of this.#map.values()) {
|
||||
const { range, draw, options, element } = obj
|
||||
this.#svg.removeChild(element)
|
||||
const rects = this.#getRects(range)
|
||||
const el = draw(rects, options)
|
||||
this.#svg.append(el)
|
||||
obj.element = el
|
||||
obj.rects = rects
|
||||
}
|
||||
}
|
||||
hitTest({ x, y }) {
|
||||
const arr = Array.from(this.#map.entries())
|
||||
// loop in reverse to hit more recently added items first
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
const tolerance = 5
|
||||
const [key, obj] = arr[i]
|
||||
for (const { left, top, right, bottom } of obj.rects) {
|
||||
if (
|
||||
top <= y + tolerance &&
|
||||
left <= x + tolerance &&
|
||||
bottom > y - tolerance &&
|
||||
right > x - tolerance
|
||||
) {
|
||||
return [key, obj.range, { left, top, right, bottom }]
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
static underline(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 2, padding = 0, writingMode } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', color)
|
||||
if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr')
|
||||
for (const { right, top, height } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', right - strokeWidth / 2 + padding)
|
||||
el.setAttribute('y', top)
|
||||
el.setAttribute('height', height)
|
||||
el.setAttribute('width', strokeWidth)
|
||||
g.append(el)
|
||||
}
|
||||
else for (const { left, bottom, width } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', left)
|
||||
el.setAttribute('y', bottom - strokeWidth / 2 + padding)
|
||||
el.setAttribute('height', strokeWidth)
|
||||
el.setAttribute('width', width)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static strikethrough(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 2, writingMode } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', color)
|
||||
if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr')
|
||||
for (const { right, left, top, height } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', (right + left) / 2)
|
||||
el.setAttribute('y', top)
|
||||
el.setAttribute('height', height)
|
||||
el.setAttribute('width', strokeWidth)
|
||||
g.append(el)
|
||||
}
|
||||
else for (const { left, top, bottom, width } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', left)
|
||||
el.setAttribute('y', (top + bottom) / 2)
|
||||
el.setAttribute('height', strokeWidth)
|
||||
el.setAttribute('width', width)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static squiggly(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 2, padding = 0, writingMode } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', 'none')
|
||||
g.setAttribute('stroke', color)
|
||||
g.setAttribute('stroke-width', strokeWidth)
|
||||
const block = strokeWidth * 1.5
|
||||
if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr')
|
||||
for (const { right, top, height } of rects) {
|
||||
const el = createSVGElement('path')
|
||||
const n = Math.round(height / block / 1.5)
|
||||
const inline = height / n
|
||||
const ls = Array.from({ length: n },
|
||||
(_, i) => `l${i % 2 ? -block : block} ${inline}`).join('')
|
||||
el.setAttribute('d', `M${right - strokeWidth / 2 + padding} ${top}${ls}`)
|
||||
g.append(el)
|
||||
}
|
||||
else for (const { left, bottom, width } of rects) {
|
||||
const el = createSVGElement('path')
|
||||
const n = Math.round(width / block / 1.5)
|
||||
const inline = width / n
|
||||
const ls = Array.from({ length: n },
|
||||
(_, i) => `l${inline} ${i % 2 ? block : -block}`).join('')
|
||||
el.setAttribute('d', `M${left} ${bottom + strokeWidth / 2 + padding}${ls}`)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static highlight(rects, options = {}) {
|
||||
const {
|
||||
color = 'red',
|
||||
padding = 0,
|
||||
radius = 4,
|
||||
radiusPadding = 2,
|
||||
vertical = false,
|
||||
} = options
|
||||
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', color)
|
||||
g.style.opacity = 'var(--overlayer-highlight-opacity, .3)'
|
||||
g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)'
|
||||
|
||||
for (const [index, { left, top, height, width }] of rects.entries()) {
|
||||
const isFirst = index === 0
|
||||
const isLast = index === rects.length - 1
|
||||
|
||||
let x, y, w, h
|
||||
|
||||
let radiusTopLeft, radiusTopRight, radiusBottomRight, radiusBottomLeft
|
||||
|
||||
if (vertical) {
|
||||
x = left - padding
|
||||
y = top - padding - (isFirst ? radiusPadding : 0)
|
||||
w = width + padding * 2
|
||||
h = height + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0)
|
||||
radiusTopLeft = isFirst ? radius : 0
|
||||
radiusTopRight = isFirst ? radius : 0
|
||||
radiusBottomRight = isLast ? radius : 0
|
||||
radiusBottomLeft = isLast ? radius : 0
|
||||
} else {
|
||||
x = left - padding - (isFirst ? radiusPadding : 0)
|
||||
y = top - padding
|
||||
w = width + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0)
|
||||
h = height + padding * 2
|
||||
radiusTopLeft = isFirst ? radius : 0
|
||||
radiusTopRight = isLast ? radius : 0
|
||||
radiusBottomRight = isLast ? radius : 0
|
||||
radiusBottomLeft = isFirst ? radius : 0
|
||||
}
|
||||
|
||||
const rtl = Math.min(radiusTopLeft, w / 2, h / 2)
|
||||
const rtr = Math.min(radiusTopRight, w / 2, h / 2)
|
||||
const rbr = Math.min(radiusBottomRight, w / 2, h / 2)
|
||||
const rbl = Math.min(radiusBottomLeft, w / 2, h / 2)
|
||||
|
||||
if (rtl === 0 && rtr === 0 && rbr === 0 && rbl === 0) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', x)
|
||||
el.setAttribute('y', y)
|
||||
el.setAttribute('height', h)
|
||||
el.setAttribute('width', w)
|
||||
g.append(el)
|
||||
} else {
|
||||
const el = createSVGElement('path')
|
||||
const d = `
|
||||
M ${x + rtl} ${y}
|
||||
L ${x + w - rtr} ${y}
|
||||
${rtr > 0 ? `Q ${x + w} ${y} ${x + w} ${y + rtr}` : `L ${x + w} ${y}`}
|
||||
L ${x + w} ${y + h - rbr}
|
||||
${rbr > 0 ? `Q ${x + w} ${y + h} ${x + w - rbr} ${y + h}` : `L ${x + w} ${y + h}`}
|
||||
L ${x + rbl} ${y + h}
|
||||
${rbl > 0 ? `Q ${x} ${y + h} ${x} ${y + h - rbl}` : `L ${x} ${y + h}`}
|
||||
L ${x} ${y + rtl}
|
||||
${rtl > 0 ? `Q ${x} ${y} ${x + rtl} ${y}` : `L ${x} ${y}`}
|
||||
Z
|
||||
`.trim().replace(/\s+/g, ' ')
|
||||
el.setAttribute('d', d)
|
||||
g.append(el)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
static outline(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 3, padding = 0, radius = 3 } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', 'none')
|
||||
g.setAttribute('stroke', color)
|
||||
g.setAttribute('stroke-width', strokeWidth)
|
||||
for (const { left, top, height, width } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', left - padding)
|
||||
el.setAttribute('y', top - padding)
|
||||
el.setAttribute('height', height + padding * 2)
|
||||
el.setAttribute('width', width + padding * 2)
|
||||
el.setAttribute('rx', radius)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static bubble(rects, options = {}) {
|
||||
const { color = '#fbbf24', writingMode, opacity = 0.85, size = 20, padding = 10 } = options
|
||||
const isVertical = writingMode === 'vertical-rl' || writingMode === 'vertical-lr'
|
||||
const g = createSVGElement('g')
|
||||
g.style.opacity = opacity
|
||||
if (rects.length === 0) return g
|
||||
rects.splice(1)
|
||||
const firstRect = rects[0]
|
||||
const x = isVertical ? firstRect.right - size + padding : firstRect.right - size + padding
|
||||
const y = isVertical ? firstRect.bottom - size + padding : firstRect.top - size + padding
|
||||
firstRect.top = y - padding
|
||||
firstRect.right = x + size + padding
|
||||
firstRect.bottom = y + size + padding
|
||||
firstRect.left = x - padding
|
||||
const bubble = createSVGElement('path')
|
||||
const s = size
|
||||
const r = s * 0.15
|
||||
// Speech bubble shape with a small tail
|
||||
// Main rounded rectangle body
|
||||
const d = `
|
||||
M ${x + r} ${y}
|
||||
h ${s - 2 * r}
|
||||
a ${r} ${r} 0 0 1 ${r} ${r}
|
||||
v ${s * 0.65 - 2 * r}
|
||||
a ${r} ${r} 0 0 1 ${-r} ${r}
|
||||
h ${-s * 0.3}
|
||||
l ${-s * 0.15} ${s * 0.2}
|
||||
l ${s * 0.05} ${-s * 0.2}
|
||||
h ${-s * 0.6 + 2 * r}
|
||||
a ${r} ${r} 0 0 1 ${-r} ${-r}
|
||||
v ${-s * 0.65 + 2 * r}
|
||||
a ${r} ${r} 0 0 1 ${r} ${-r}
|
||||
z
|
||||
`.replace(/\s+/g, ' ').trim()
|
||||
|
||||
bubble.setAttribute('d', d)
|
||||
bubble.setAttribute('fill', color)
|
||||
bubble.setAttribute('stroke', 'rgba(0, 0, 0, 0.2)')
|
||||
bubble.setAttribute('stroke-width', '1')
|
||||
// Add horizontal lines inside to represent text
|
||||
const lineGroup = createSVGElement('g')
|
||||
lineGroup.setAttribute('stroke', 'rgba(0, 0, 0, 0.3)')
|
||||
lineGroup.setAttribute('stroke-width', '1.5')
|
||||
lineGroup.setAttribute('stroke-linecap', 'round')
|
||||
const lineY1 = y + s * 0.18
|
||||
const lineY2 = y + s * 0.33
|
||||
const lineY3 = y + s * 0.48
|
||||
const lineX1 = x + s * 0.2
|
||||
const lineX2 = x + s * 0.8
|
||||
const line1 = createSVGElement('line')
|
||||
line1.setAttribute('x1', lineX1)
|
||||
line1.setAttribute('y1', lineY1)
|
||||
line1.setAttribute('x2', lineX2)
|
||||
line1.setAttribute('y2', lineY1)
|
||||
const line2 = createSVGElement('line')
|
||||
line2.setAttribute('x1', lineX1)
|
||||
line2.setAttribute('y1', lineY2)
|
||||
line2.setAttribute('x2', lineX2)
|
||||
line2.setAttribute('y2', lineY2)
|
||||
const line3 = createSVGElement('line')
|
||||
line3.setAttribute('x1', lineX1)
|
||||
line3.setAttribute('y1', lineY3)
|
||||
line3.setAttribute('x2', x + s * 0.6)
|
||||
line3.setAttribute('y2', lineY3)
|
||||
lineGroup.append(line1, line2, line3)
|
||||
|
||||
if (isVertical) {
|
||||
const centerX = x + s / 2
|
||||
const centerY = y + s / 2
|
||||
bubble.setAttribute('transform', `rotate(90 ${centerX} ${centerY})`)
|
||||
lineGroup.setAttribute('transform', `rotate(90 ${centerX} ${centerY})`)
|
||||
}
|
||||
|
||||
g.append(bubble)
|
||||
g.append(lineGroup)
|
||||
return g
|
||||
}
|
||||
// make an exact copy of an image in the overlay
|
||||
// one can then apply filters to the entire element, without affecting them;
|
||||
// it's a bit silly and probably better to just invert images twice
|
||||
// (though the color will be off in that case if you do heu-rotate)
|
||||
static copyImage([rect], options = {}) {
|
||||
const { src } = options
|
||||
const image = createSVGElement('image')
|
||||
const { left, top, height, width } = rect
|
||||
image.setAttribute('href', src)
|
||||
image.setAttribute('x', left)
|
||||
image.setAttribute('y', top)
|
||||
image.setAttribute('height', height)
|
||||
image.setAttribute('width', width)
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
+3794
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
||||
// NOT upstream foliate-js. See README.chitai.md.
|
||||
//
|
||||
// Chitai renders PDFs with the pdf.js viewer vendored at static/pdfjs/, so
|
||||
// foliate's PDF backend is not vendored. view.js still references this module
|
||||
// from makeBook via a static-string dynamic import, which Rollup resolves at
|
||||
// build time regardless of whether it executes — so the file has to exist.
|
||||
//
|
||||
// Throwing at module scope surfaces a legible message in the reader's error
|
||||
// card if a PDF is ever routed to the EPUB reader by mistake, rather than a
|
||||
// TypeError from `globalThis.pdfjsLib` being undefined.
|
||||
throw new Error('foliate-js PDF rendering is not enabled in Chitai');
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
// assign a unique ID for each TOC item
|
||||
const assignIDs = toc => {
|
||||
let id = 0
|
||||
const assignID = item => {
|
||||
item.id = id++
|
||||
if (item.subitems) for (const subitem of item.subitems) assignID(subitem)
|
||||
}
|
||||
for (const item of toc) assignID(item)
|
||||
return toc
|
||||
}
|
||||
|
||||
const flatten = items => items
|
||||
.map(item => item.subitems?.length
|
||||
? [item, flatten(item.subitems)].flat()
|
||||
: item)
|
||||
.flat()
|
||||
|
||||
export class TOCProgress {
|
||||
async init({ toc, ids, splitHref, getFragment }) {
|
||||
assignIDs(toc)
|
||||
const items = flatten(toc)
|
||||
const grouped = new Map()
|
||||
for (const [i, item] of items.entries()) {
|
||||
const [id, fragment] = await splitHref(item?.href) ?? []
|
||||
const value = { fragment, item }
|
||||
if (grouped.has(id)) grouped.get(id).items.push(value)
|
||||
else grouped.set(id, { prev: items[i - 1], items: [value] })
|
||||
}
|
||||
const map = new Map()
|
||||
for (const [i, id] of ids.entries()) {
|
||||
if (grouped.has(id)) map.set(id, grouped.get(id))
|
||||
else map.set(id, map.get(ids[i - 1]))
|
||||
}
|
||||
this.ids = ids
|
||||
this.map = map
|
||||
this.getFragment = getFragment
|
||||
}
|
||||
getProgress(index, range) {
|
||||
if (!this.ids) return
|
||||
const id = this.ids[index]
|
||||
const obj = this.map.get(id)
|
||||
if (!obj) return null
|
||||
const { prev, items } = obj
|
||||
if (!items) return prev
|
||||
if (!range || items.length === 1 && !items[0].fragment) return items[0].item
|
||||
|
||||
const doc = range.startContainer.getRootNode()
|
||||
for (const [i, { fragment }] of items.entries()) {
|
||||
const el = this.getFragment(doc, fragment)
|
||||
if (!el) continue
|
||||
if (range.comparePoint(el, 0) > 0)
|
||||
return (items[i - 1]?.item ?? prev)
|
||||
}
|
||||
return items[items.length - 1].item
|
||||
}
|
||||
}
|
||||
|
||||
export class PageProgress {
|
||||
#book
|
||||
#cache = new Map()
|
||||
#resolveNavigation
|
||||
|
||||
constructor(book, resolveNavigation) {
|
||||
this.#book = book
|
||||
this.#resolveNavigation = resolveNavigation
|
||||
}
|
||||
|
||||
async #getCache(index) {
|
||||
let cached = this.#cache.get(index)
|
||||
if (cached) return cached
|
||||
|
||||
const section = this.#book.sections[index]
|
||||
if (!section?.createDocument) return null
|
||||
|
||||
const doc = await section.createDocument()
|
||||
const root = doc.body ?? doc.documentElement
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const nodes = []
|
||||
const offsets = []
|
||||
let total = 0
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const len = node.nodeValue?.length ?? 0
|
||||
nodes.push(node)
|
||||
offsets.push(total)
|
||||
total += len
|
||||
}
|
||||
|
||||
cached = { doc, nodes, offsets, total }
|
||||
this.#cache.set(index, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
async getProgress(cfi) {
|
||||
try {
|
||||
const nav = this.#resolveNavigation(cfi)
|
||||
if (!nav) return null
|
||||
|
||||
const { index, anchor } = nav
|
||||
if (index == null || !anchor) return null
|
||||
|
||||
const cached = await this.#getCache(index)
|
||||
if (!cached) return null
|
||||
|
||||
const { doc, nodes, offsets, total } = cached
|
||||
const frag = anchor(doc)
|
||||
if (!frag) return null
|
||||
|
||||
const isRange = frag instanceof Range
|
||||
const range = isRange ? frag : doc.createRange()
|
||||
if (!isRange) range.selectNodeContents(frag)
|
||||
|
||||
const offset = this.#findOffset(range, nodes, offsets, total)
|
||||
return {
|
||||
fraction: total > 0 ? offset / total : 0,
|
||||
index,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
#findOffset(range, nodes, offsets, total) {
|
||||
if (!nodes.length) return 0
|
||||
const container = range.startContainer
|
||||
// fast path: startContainer is a text node in the index
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
const i = this.#bsearchNode(container, nodes)
|
||||
if (i >= 0) return offsets[i] + range.startOffset
|
||||
}
|
||||
// element container: collapse to start and binary search
|
||||
const collapsed = range.cloneRange()
|
||||
collapsed.collapse(true)
|
||||
const i = this.#bsearchCollapsed(collapsed, nodes)
|
||||
return i >= 0 ? offsets[i] : total
|
||||
}
|
||||
|
||||
// binary search for an exact text node by document position
|
||||
#bsearchNode(target, nodes) {
|
||||
let low = 0, high = nodes.length - 1
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1
|
||||
const node = nodes[mid]
|
||||
if (node === target) return mid
|
||||
const pos = node.compareDocumentPosition(target)
|
||||
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) low = mid + 1
|
||||
else high = mid - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// binary search for the first text node at or after a collapsed range point
|
||||
// collapsed.comparePoint returns: -1 = node is before, 1 = node is at/after
|
||||
#bsearchCollapsed(collapsed, nodes) {
|
||||
let low = 0, high = nodes.length - 1, result = -1
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1
|
||||
if (collapsed.comparePoint(nodes[mid], 0) > 0) {
|
||||
result = mid
|
||||
high = mid - 1
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export class SectionProgress {
|
||||
constructor(sections, sizePerLoc, sizePerTimeUnit) {
|
||||
this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0)
|
||||
this.sizePerLoc = sizePerLoc
|
||||
this.sizePerTimeUnit = sizePerTimeUnit
|
||||
this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0)
|
||||
this.sectionFractions = this.#getSectionFractions()
|
||||
}
|
||||
#getSectionFractions() {
|
||||
const { sizeTotal } = this
|
||||
const results = [0]
|
||||
let sum = 0
|
||||
for (const size of this.sizes) results.push((sum += size) / sizeTotal)
|
||||
return results
|
||||
}
|
||||
// get progress given index of and fractions within a section
|
||||
getProgress(index, fractionInSection, pageFraction = 0) {
|
||||
const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this
|
||||
const sizeInSection = sizes[index] ?? 0
|
||||
const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0)
|
||||
const size = sizeBefore + fractionInSection * sizeInSection
|
||||
const nextSize = size + pageFraction * sizeInSection
|
||||
const remainingTotal = sizeTotal - size
|
||||
const remainingSection = (1 - fractionInSection) * sizeInSection
|
||||
return {
|
||||
fraction: nextSize / sizeTotal,
|
||||
section: {
|
||||
current: index,
|
||||
total: sizes.length,
|
||||
},
|
||||
location: {
|
||||
current: Math.floor(size / sizePerLoc),
|
||||
next: Math.floor(nextSize / sizePerLoc),
|
||||
total: Math.ceil(sizeTotal / sizePerLoc),
|
||||
},
|
||||
time: {
|
||||
section: remainingSection / sizePerTimeUnit,
|
||||
total: remainingTotal / sizePerTimeUnit,
|
||||
},
|
||||
}
|
||||
}
|
||||
// the inverse of `getProgress`
|
||||
// get index of and fraction in section based on total fraction
|
||||
getSection(fraction) {
|
||||
if (fraction <= 0) return [0, 0]
|
||||
if (fraction >= 1) return [this.sizes.length - 1, 1]
|
||||
fraction = fraction + Number.EPSILON
|
||||
const { sizeTotal } = this
|
||||
let index = this.sectionFractions.findIndex(x => x > fraction) - 1
|
||||
if (index < 0) return [0, 0]
|
||||
while (!this.sizes[index]) index++
|
||||
const fractionInSection = (fraction - this.sectionFractions[index])
|
||||
/ (this.sizes[index] / sizeTotal)
|
||||
return [index, fractionInSection]
|
||||
}
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
// length for context in excerpts
|
||||
const CONTEXT_LENGTH = 50
|
||||
|
||||
const normalizeWhitespace = str => str.replace(/\s+/g, ' ')
|
||||
|
||||
// Gather context preceding the match by walking back across text nodes until we
|
||||
// have enough for the excerpt. A match can sit in its own text node (e.g. a word
|
||||
// wrapped in <i>/<em>/<b>), leaving no context within the start node itself.
|
||||
const collectBefore = (strs, index, offset) => {
|
||||
let str = strs[index].slice(0, offset)
|
||||
for (let i = index - 1; i >= 0 && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i--)
|
||||
str = strs[i] + str
|
||||
return str
|
||||
}
|
||||
|
||||
const collectAfter = (strs, index, offset) => {
|
||||
let str = strs[index].slice(offset)
|
||||
for (let i = index + 1; i < strs.length && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i++)
|
||||
str += strs[i]
|
||||
return str
|
||||
}
|
||||
|
||||
const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => {
|
||||
const start = strs[startIndex]
|
||||
const end = strs[endIndex]
|
||||
const match = startIndex === endIndex
|
||||
? start.slice(startOffset, endOffset)
|
||||
: start.slice(startOffset)
|
||||
+ strs.slice(startIndex + 1, endIndex).join('')
|
||||
+ end.slice(0, endOffset)
|
||||
const trimmedStart = normalizeWhitespace(collectBefore(strs, startIndex, startOffset)).trimStart()
|
||||
const trimmedEnd = normalizeWhitespace(collectAfter(strs, endIndex, endOffset)).trimEnd()
|
||||
const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…'
|
||||
const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…'
|
||||
const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}`
|
||||
const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}`
|
||||
return { pre, match, post }
|
||||
}
|
||||
|
||||
// Cumulative character offsets of the joined `strs`, so a flat offset into
|
||||
// `strs.join('')` can be mapped back to a (node index, in-node offset) pair.
|
||||
const buildCum = strs => {
|
||||
const cum = [0]
|
||||
for (let i = 0; i < strs.length; i++) cum.push(cum[i] + strs[i].length)
|
||||
return cum
|
||||
}
|
||||
|
||||
// Largest node i with cum[i] <= offset; clamps the end-of-text offset to the
|
||||
// last node's end. Works for both range start and (exclusive) end positions.
|
||||
const nodeAt = (cum, offset) => {
|
||||
let lo = 0, hi = cum.length - 2
|
||||
if (hi < 0) return { index: 0, offset: 0 }
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1
|
||||
if (cum[mid] <= offset) lo = mid
|
||||
else hi = mid - 1
|
||||
}
|
||||
return { index: lo, offset: offset - cum[lo] }
|
||||
}
|
||||
|
||||
const rangeFromFlat = (cum, start, end) => {
|
||||
const s = nodeAt(cum, start)
|
||||
const e = nodeAt(cum, end)
|
||||
return { startIndex: s.index, startOffset: s.offset, endIndex: e.index, endOffset: e.offset }
|
||||
}
|
||||
|
||||
const simpleSearch = function* (strs, query, options = {}) {
|
||||
const { locales = 'en', sensitivity } = options
|
||||
const matchCase = sensitivity === 'variant'
|
||||
const haystack = strs.join('')
|
||||
const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales)
|
||||
const needle = matchCase ? query : query.toLocaleLowerCase(locales)
|
||||
const needleLength = needle.length
|
||||
let index = -1
|
||||
let strIndex = -1
|
||||
let sum = 0
|
||||
do {
|
||||
index = lowerHaystack.indexOf(needle, index + 1)
|
||||
if (index > -1) {
|
||||
while (sum <= index) sum += strs[++strIndex].length
|
||||
const startIndex = strIndex
|
||||
const startOffset = index - (sum - strs[strIndex].length)
|
||||
const end = index + needleLength
|
||||
while (sum <= end) sum += strs[++strIndex].length
|
||||
const endIndex = strIndex
|
||||
const endOffset = end - (sum - strs[strIndex].length)
|
||||
const range = { startIndex, startOffset, endIndex, endOffset }
|
||||
yield { range, excerpt: makeExcerpt(strs, range) }
|
||||
}
|
||||
} while (index > -1)
|
||||
}
|
||||
|
||||
const segmenterSearch = function* (strs, query, options = {}) {
|
||||
const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options
|
||||
let segmenter, collator
|
||||
try {
|
||||
segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity })
|
||||
collator = new Intl.Collator(locales, { sensitivity })
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
segmenter = new Intl.Segmenter('en', { usage: 'search', granularity })
|
||||
collator = new Intl.Collator('en', { sensitivity })
|
||||
}
|
||||
const queryLength = Array.from(segmenter.segment(query)).length
|
||||
|
||||
const substrArr = []
|
||||
let strIndex = 0
|
||||
let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
|
||||
main: while (strIndex < strs.length) {
|
||||
while (substrArr.length < queryLength) {
|
||||
const { done, value } = segments.next()
|
||||
if (done) {
|
||||
// the current string is exhausted
|
||||
// move on to the next string
|
||||
strIndex++
|
||||
if (strIndex < strs.length) {
|
||||
segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
|
||||
continue
|
||||
} else break main
|
||||
}
|
||||
const { index, segment } = value
|
||||
// ignore formatting characters
|
||||
if (!/[^\p{Format}]/u.test(segment)) continue
|
||||
// normalize whitespace
|
||||
if (/\s/u.test(segment)) {
|
||||
if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment))
|
||||
substrArr.push({ strIndex, index, segment: ' ' })
|
||||
continue
|
||||
}
|
||||
value.strIndex = strIndex
|
||||
substrArr.push(value)
|
||||
}
|
||||
const substr = substrArr.map(x => x.segment).join('')
|
||||
if (collator.compare(query, substr) === 0) {
|
||||
const endIndex = strIndex
|
||||
const lastSeg = substrArr[substrArr.length - 1]
|
||||
const endOffset = lastSeg.index + lastSeg.segment.length
|
||||
const startIndex = substrArr[0].strIndex
|
||||
const startOffset = substrArr[0].index
|
||||
const range = { startIndex, startOffset, endIndex, endOffset }
|
||||
yield { range, excerpt: makeExcerpt(strs, range) }
|
||||
}
|
||||
substrArr.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// Calibre-parity regex mode (#4560). Runs a JS RegExp over the joined text and
|
||||
// maps each match back to a node range. Note: RegExp.exec is synchronous, so a
|
||||
// catastrophic pattern can still stall this pass — true interruption (a Web
|
||||
// Worker) is left to a follow-up; here we only cap match count and reject
|
||||
// invalid patterns. The caller surfaces INVALID_REGEX as a calm inline error.
|
||||
const MAX_REGEX_MATCHES = 10000
|
||||
const regexSearch = function* (strs, query, options = {}) {
|
||||
const { matchCase } = options
|
||||
const flags = matchCase ? 'g' : 'gi'
|
||||
let re
|
||||
try {
|
||||
re = new RegExp(query, flags + 'u')
|
||||
} catch {
|
||||
// Some patterns are valid only without the unicode flag; fall back.
|
||||
try {
|
||||
re = new RegExp(query, flags)
|
||||
} catch (e) {
|
||||
const err = new Error(`Invalid regular expression: ${e.message}`)
|
||||
err.code = 'INVALID_REGEX'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const haystack = strs.join('')
|
||||
const cum = buildCum(strs)
|
||||
let count = 0
|
||||
let m
|
||||
while ((m = re.exec(haystack)) !== null) {
|
||||
if (m[0].length === 0) {
|
||||
// zero-width match: advance to avoid an infinite loop
|
||||
re.lastIndex = m.index + 1
|
||||
continue
|
||||
}
|
||||
const range = rangeFromFlat(cum, m.index, m.index + m[0].length)
|
||||
yield { range, excerpt: makeExcerpt(strs, range) }
|
||||
if (++count >= MAX_REGEX_MATCHES) break
|
||||
}
|
||||
}
|
||||
|
||||
// Segmented excerpt for nearby-words: emphasizes only the matched words inside
|
||||
// the cluster window, leaving the gap text un-emphasized. `pre`/`match`/`post`
|
||||
// stay populated for consumers that don't render segments.
|
||||
const makeNearbyExcerpt = (haystack, matched) => {
|
||||
const clusterStart = matched[0].start
|
||||
const clusterEnd = matched[matched.length - 1].end
|
||||
const trimmedStart = normalizeWhitespace(haystack.slice(0, clusterStart)).trimStart()
|
||||
const trimmedEnd = normalizeWhitespace(haystack.slice(clusterEnd)).trimEnd()
|
||||
const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…'
|
||||
const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…'
|
||||
const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}`
|
||||
const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}`
|
||||
const segments = []
|
||||
let cursor = clusterStart
|
||||
for (const o of matched) {
|
||||
if (o.start > cursor) {
|
||||
const gap = normalizeWhitespace(haystack.slice(cursor, o.start))
|
||||
if (gap) segments.push({ text: gap, emphasized: false })
|
||||
}
|
||||
segments.push({ text: normalizeWhitespace(haystack.slice(o.start, o.end)), emphasized: true })
|
||||
cursor = o.end
|
||||
}
|
||||
const match = normalizeWhitespace(haystack.slice(clusterStart, clusterEnd))
|
||||
return { pre, match, post, segments }
|
||||
}
|
||||
|
||||
// Calibre-parity nearby-words mode (#4560): matches places where all of the
|
||||
// query's distinct whole words occur within `nearbyWords` words of each other.
|
||||
// Distance is measured in words (not characters) and comes from the option, not
|
||||
// from the query string, so trailing numbers stay literal search words.
|
||||
const nearbyWordsSearch = function* (strs, query, options = {}) {
|
||||
const { locales = 'en', sensitivity = 'base', nearbyWords = 10 } = options
|
||||
const queryWords = []
|
||||
for (const w of query.split(/\s+/).filter(Boolean)) if (!queryWords.includes(w)) queryWords.push(w)
|
||||
if (queryWords.length < 2) {
|
||||
const err = new Error('Nearby words search needs at least two words')
|
||||
err.code = 'NEARBY_NEEDS_TWO_WORDS'
|
||||
throw err
|
||||
}
|
||||
let segmenter, collator
|
||||
try {
|
||||
segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity: 'word' })
|
||||
collator = new Intl.Collator(locales, { sensitivity })
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
segmenter = new Intl.Segmenter('en', { usage: 'search', granularity: 'word' })
|
||||
collator = new Intl.Collator('en', { sensitivity })
|
||||
}
|
||||
const haystack = strs.join('')
|
||||
const cum = buildCum(strs)
|
||||
const K = queryWords.length
|
||||
|
||||
// Word occurrences of any query word, tagged with a global word index.
|
||||
const occ = []
|
||||
let wordIndex = -1
|
||||
for (const seg of segmenter.segment(haystack)) {
|
||||
if (!seg.isWordLike) continue
|
||||
wordIndex++
|
||||
for (let q = 0; q < K; q++) {
|
||||
if (collator.compare(queryWords[q], seg.segment) === 0) {
|
||||
occ.push({ wordIndex, qIdx: q, start: seg.index, end: seg.index + seg.segment.length })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smallest window covering all K distinct query words, two-pointer scan.
|
||||
const have = new Array(K).fill(0)
|
||||
let distinct = 0
|
||||
let lo = 0
|
||||
const windows = []
|
||||
for (let hi = 0; hi < occ.length; hi++) {
|
||||
if (have[occ[hi].qIdx]++ === 0) distinct++
|
||||
let minimal = null
|
||||
while (distinct === K) {
|
||||
minimal = { lo, hi }
|
||||
if (--have[occ[lo].qIdx] === 0) distinct--
|
||||
lo++
|
||||
}
|
||||
if (minimal && occ[minimal.hi].wordIndex - occ[minimal.lo].wordIndex <= nearbyWords)
|
||||
windows.push(minimal)
|
||||
}
|
||||
|
||||
// One cluster per window; suppress windows overlapping an already-emitted one.
|
||||
let lastHi = -1
|
||||
for (const w of windows) {
|
||||
if (w.lo <= lastHi) continue
|
||||
lastHi = w.hi
|
||||
const matched = occ.slice(w.lo, w.hi + 1)
|
||||
const excerpt = makeNearbyExcerpt(haystack, matched)
|
||||
const range = rangeFromFlat(cum, matched[0].start, matched[matched.length - 1].end)
|
||||
const subRanges = matched.map(o => rangeFromFlat(cum, o.start, o.end))
|
||||
yield { range, excerpt, subRanges }
|
||||
}
|
||||
}
|
||||
|
||||
export const search = (strs, query, options) => {
|
||||
const { mode } = options
|
||||
if (mode === 'regex') return regexSearch(strs, query, options)
|
||||
if (mode === 'nearby-words') return nearbyWordsSearch(strs, query, options)
|
||||
const { granularity = 'grapheme', sensitivity = 'base' } = options
|
||||
if (!Intl?.Segmenter || granularity === 'grapheme'
|
||||
&& (sensitivity === 'variant' || sensitivity === 'accent'))
|
||||
return simpleSearch(strs, query, options)
|
||||
return segmenterSearch(strs, query, options)
|
||||
}
|
||||
|
||||
export const searchMatcher = (textWalker, opts) => {
|
||||
const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, mode, nearbyWords, acceptNode } = opts
|
||||
const effectiveMode = mode ?? (matchWholeWords ? 'whole-words' : 'contains')
|
||||
return function* (doc, query) {
|
||||
const iter = textWalker(doc, function* (strs, makeRange) {
|
||||
for (const result of search(strs, query, {
|
||||
mode: effectiveMode,
|
||||
nearbyWords,
|
||||
matchCase,
|
||||
locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en',
|
||||
granularity: effectiveMode === 'whole-words' ? 'word' : 'grapheme',
|
||||
sensitivity: matchDiacritics && matchCase ? 'variant'
|
||||
: matchDiacritics && !matchCase ? 'accent'
|
||||
: !matchDiacritics && matchCase ? 'case'
|
||||
: 'base',
|
||||
})) {
|
||||
const { startIndex, startOffset, endIndex, endOffset } = result.range
|
||||
result.range = makeRange(startIndex, startOffset, endIndex, endOffset)
|
||||
if (result.subRanges) result.subRanges = result.subRanges.map(
|
||||
r => makeRange(r.startIndex, r.startOffset, r.endIndex, r.endOffset))
|
||||
yield result
|
||||
}
|
||||
}, acceptNode)
|
||||
for (const result of iter) yield result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const walkRange = (range, walker) => {
|
||||
const nodes = []
|
||||
for (let node = walker.currentNode; node; node = walker.nextNode()) {
|
||||
const compare = range.comparePoint(node, 0)
|
||||
if (compare === 0) nodes.push(node)
|
||||
else if (compare > 0) break
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
const walkDocument = (_, walker) => {
|
||||
const nodes = []
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
||||
nodes.push(node)
|
||||
return nodes
|
||||
}
|
||||
|
||||
const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
|
||||
| NodeFilter.SHOW_CDATA_SECTION
|
||||
|
||||
const acceptNode = node => {
|
||||
if (node.nodeType === 1) {
|
||||
const name = node.tagName.toLowerCase()
|
||||
if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
|
||||
export const textWalker = function* (x, func, filterFunc) {
|
||||
const root = x.commonAncestorContainer ?? x.body ?? x
|
||||
const walker = document.createTreeWalker(root, filter, { acceptNode: filterFunc || acceptNode })
|
||||
const walk = x.commonAncestorContainer ? walkRange : walkDocument
|
||||
const nodes = walk(x, walker)
|
||||
const strs = nodes.map(node => node.nodeValue ?? '')
|
||||
const makeRange = (startIndex, startOffset, endIndex, endOffset) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(nodes[startIndex], startOffset)
|
||||
range.setEnd(nodes[endIndex], endOffset)
|
||||
return range
|
||||
}
|
||||
for (const match of func(strs, makeRange)) yield match
|
||||
}
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
const NS = {
|
||||
XML: 'http://www.w3.org/XML/1998/namespace',
|
||||
SSML: 'http://www.w3.org/2001/10/synthesis',
|
||||
}
|
||||
|
||||
const blockTags = new Set([
|
||||
'article', 'aside', 'audio', 'blockquote', 'caption',
|
||||
'details', 'dialog', 'div', 'dl', 'dt', 'dd',
|
||||
'figure', 'footer', 'form', 'figcaption',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li',
|
||||
'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr',
|
||||
])
|
||||
|
||||
const getLang = el => {
|
||||
const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang')
|
||||
return x ? x : el.parentElement ? getLang(el.parentElement) : null
|
||||
}
|
||||
|
||||
const getAlphabet = el => {
|
||||
const x = el?.getAttributeNS?.(NS.XML, 'lang')
|
||||
return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null
|
||||
}
|
||||
|
||||
const getSegmenter = (lang, granularity = 'word') => {
|
||||
const segmenter = new Intl.Segmenter(lang || undefined, { granularity })
|
||||
const granularityIsWord = granularity === 'word'
|
||||
return function* (strs, makeRange) {
|
||||
const str = strs.join('').replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ')
|
||||
let name = 0
|
||||
let strIndex = -1
|
||||
let sum = 0
|
||||
const rawSegments = Array.from(segmenter.segment(str))
|
||||
const mergedSegments = []
|
||||
for (let i = 0, j = 0; i < rawSegments.length; i++) {
|
||||
const current = rawSegments[i]
|
||||
const segment = ' ' + current.segment
|
||||
const endsWithAbbr = /\s([A-Z]{1,2}[a-z]{0,5}|[a-z]{1,3})\.\s*$/.test(segment)
|
||||
if (!endsWithAbbr || i >= (rawSegments.length-1)) {
|
||||
const mergedSegment = {
|
||||
index: rawSegments[j].index,
|
||||
segment: '',
|
||||
isWordLike: (i == j) ? current.isWordLike : true,
|
||||
}
|
||||
while (j <= i) {
|
||||
mergedSegment.segment += rawSegments[j++].segment
|
||||
}
|
||||
mergedSegments.push(mergedSegment)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { index, segment, isWordLike } of mergedSegments) {
|
||||
if (granularityIsWord && !isWordLike) continue
|
||||
while (sum <= index) sum += strs[++strIndex].length
|
||||
const startIndex = strIndex
|
||||
const startOffset = index - (sum - strs[strIndex].length)
|
||||
const end = index + segment.length - 1
|
||||
if (end < str.length) while (sum <= end) sum += strs[++strIndex].length
|
||||
const endIndex = strIndex
|
||||
const endOffset = end - (sum - strs[strIndex].length) + 1
|
||||
yield [(name++).toString(),
|
||||
makeRange(startIndex, startOffset, endIndex, endOffset)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fragmentToSSML = (fragment, nodeFilter, inherited) => {
|
||||
const ssml = document.implementation.createDocument(NS.SSML, 'speak')
|
||||
const { lang } = inherited
|
||||
if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang)
|
||||
|
||||
const convert = (node, parent, inheritedAlphabet) => {
|
||||
if (!node) return
|
||||
// Text nodes go through the filter too: the text walker that produces
|
||||
// the marks already honours it, and content that is skipped there (a
|
||||
// bare ruby base inside <ruby>, say) must not reach the speech either,
|
||||
// or the two disagree about what is being read.
|
||||
if (node.nodeType === 3 || node.nodeType === 4) {
|
||||
if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return
|
||||
return node.nodeType === 3
|
||||
? ssml.createTextNode(node.textContent)
|
||||
: ssml.createCDATASection(node.textContent)
|
||||
}
|
||||
if (node.nodeType !== 1 && node.nodeType !== 11) return
|
||||
if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return
|
||||
|
||||
let el
|
||||
const nodeName = node.nodeName.toLowerCase()
|
||||
if (nodeName === 'foliate-mark') {
|
||||
el = ssml.createElementNS(NS.SSML, 'mark')
|
||||
el.setAttribute('name', node.dataset.name)
|
||||
}
|
||||
else if (nodeName === 'br')
|
||||
el = ssml.createElementNS(NS.SSML, 'break')
|
||||
else if (nodeName === 'em' || nodeName === 'strong')
|
||||
el = ssml.createElementNS(NS.SSML, 'emphasis')
|
||||
|
||||
const lang = node.lang || node.getAttributeNS?.(NS.XML, 'lang')
|
||||
if (lang) {
|
||||
if (!el) el = ssml.createElementNS(NS.SSML, 'lang')
|
||||
el.setAttributeNS(NS.XML, 'lang', lang)
|
||||
}
|
||||
|
||||
const alphabet = node.getAttributeNS?.(NS.SSML, 'alphabet') || inheritedAlphabet
|
||||
if (!el) {
|
||||
const ph = node.getAttributeNS?.(NS.SSML, 'ph')
|
||||
if (ph) {
|
||||
el = ssml.createElementNS(NS.SSML, 'phoneme')
|
||||
if (alphabet) el.setAttribute('alphabet', alphabet)
|
||||
el.setAttribute('ph', ph)
|
||||
}
|
||||
}
|
||||
|
||||
if (!el) el = parent
|
||||
|
||||
let child = node.firstChild
|
||||
while (child) {
|
||||
const childEl = convert(child, el, alphabet)
|
||||
if (childEl && el !== childEl) el.append(childEl)
|
||||
child = child.nextSibling
|
||||
}
|
||||
return el
|
||||
}
|
||||
convert(fragment, ssml.documentElement, inherited.alphabet)
|
||||
return ssml
|
||||
}
|
||||
|
||||
const getFragmentWithMarks = (range, textWalker, nodeFilter, granularity) => {
|
||||
const lang = getLang(range.commonAncestorContainer)
|
||||
const alphabet = getAlphabet(range.commonAncestorContainer)
|
||||
|
||||
const segmenter = getSegmenter(lang, granularity)
|
||||
const fragment = range.cloneContents()
|
||||
|
||||
// we need ranges on both the original document (for highlighting)
|
||||
// and the document fragment (for inserting marks)
|
||||
// so unfortunately need to do it twice, as you can't copy the ranges
|
||||
const entries = [...textWalker(range, segmenter, nodeFilter)]
|
||||
const fragmentEntries = [...textWalker(fragment, segmenter, nodeFilter)]
|
||||
|
||||
for (const [name, range] of fragmentEntries) {
|
||||
const mark = document.createElement('foliate-mark')
|
||||
mark.dataset.name = name
|
||||
range.insertNode(mark)
|
||||
}
|
||||
const ssml = fragmentToSSML(fragment, nodeFilter, { lang, alphabet })
|
||||
return { entries, ssml }
|
||||
}
|
||||
|
||||
const rangeIsEmpty = range => !range.toString().trim()
|
||||
|
||||
// For PDF text layers, split content into sentence-level blocks so TTS
|
||||
// reads one sentence at a time instead of the whole page in one block.
|
||||
// Text nodes are split at sentence boundaries so that every block range
|
||||
// aligns with node edges — this prevents the text walker from including
|
||||
// text outside the sentence in word marks.
|
||||
function* getPDFSentenceBlocks(doc, textLayer) {
|
||||
const collectNodes = () => {
|
||||
const w = doc.createTreeWalker(textLayer, NodeFilter.SHOW_TEXT)
|
||||
const res = []
|
||||
for (let n = w.nextNode(); n; n = w.nextNode()) res.push(n)
|
||||
return res
|
||||
}
|
||||
|
||||
let nodes = collectNodes()
|
||||
if (!nodes.length) return
|
||||
|
||||
const fullText = nodes.map(n => n.nodeValue).join('')
|
||||
if (!fullText.trim()) return
|
||||
|
||||
// Find sentence boundary positions
|
||||
const lang = getLang(textLayer) || undefined
|
||||
const segmenter = new Intl.Segmenter(lang, { granularity: 'sentence' })
|
||||
const boundaries = new Set()
|
||||
for (const { index } of segmenter.segment(fullText))
|
||||
if (index > 0) boundaries.add(index)
|
||||
|
||||
// Split text nodes at sentence boundaries so ranges align with node edges.
|
||||
// Process in reverse order to preserve earlier character positions.
|
||||
let cum = 0
|
||||
const nodeStarts = nodes.map(n => { const s = cum; cum += n.nodeValue.length; return s })
|
||||
|
||||
for (const pos of [...boundaries].sort((a, b) => b - a)) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const start = nodeStarts[i]
|
||||
const end = start + nodes[i].nodeValue.length
|
||||
if (pos > start && pos < end) {
|
||||
nodes[i].splitText(pos - start)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-collect nodes after splits and group into sentence blocks
|
||||
nodes = collectNodes()
|
||||
cum = 0
|
||||
let groupStart = 0
|
||||
let blockCount = 0
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
cum += nodes[i].nodeValue.length
|
||||
const isEnd = i === nodes.length - 1 || boundaries.has(cum)
|
||||
if (isEnd) {
|
||||
const range = doc.createRange()
|
||||
range.setStart(nodes[groupStart], 0)
|
||||
range.setEnd(nodes[i], nodes[i].nodeValue.length)
|
||||
if (!rangeIsEmpty(range)) {
|
||||
blockCount++
|
||||
yield range
|
||||
}
|
||||
groupStart = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* getBlocks(doc, nodeFilter) {
|
||||
const root = doc.body
|
||||
?? doc.querySelector('body')
|
||||
?? doc.documentElement
|
||||
|
||||
// For PDF text layers, yield sentence-level blocks
|
||||
const textLayer = root.querySelector?.('.textLayer')
|
||||
if (textLayer) {
|
||||
yield* getPDFSentenceBlocks(doc, textLayer)
|
||||
return
|
||||
}
|
||||
|
||||
let last
|
||||
let sawBlock = false
|
||||
let sawSkipped = false
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
||||
let node = walker.nextNode()
|
||||
while (node) {
|
||||
const name = node.tagName.toLowerCase()
|
||||
// A rejected block element (e.g. a footnote/endnote aside) must not be
|
||||
// read: skip its whole subtree and end the preceding block before it
|
||||
// so its text doesn't leak into the adjacent block. Inline rejects are
|
||||
// left to the text walker in getFragmentWithMarks().
|
||||
if (blockTags.has(name)
|
||||
&& nodeFilter?.(node) === NodeFilter.FILTER_REJECT) {
|
||||
sawSkipped = true
|
||||
if (last) {
|
||||
last.setEndBefore(node)
|
||||
if (!rangeIsEmpty(last)) yield last
|
||||
last = null
|
||||
}
|
||||
const skipped = node
|
||||
do node = walker.nextNode()
|
||||
while (node && (skipped.compareDocumentPosition(node)
|
||||
& Node.DOCUMENT_POSITION_CONTAINED_BY))
|
||||
continue
|
||||
}
|
||||
if (blockTags.has(name)) {
|
||||
if (last) {
|
||||
last.setEndBefore(node)
|
||||
if (!rangeIsEmpty(last)) yield last
|
||||
}
|
||||
last = doc.createRange()
|
||||
last.setStart(node, 0)
|
||||
sawBlock = true
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
if (last) {
|
||||
last.setEndAfter(root.lastChild ?? root)
|
||||
if (!rangeIsEmpty(last)) yield last
|
||||
} else if (!sawBlock && !sawSkipped) {
|
||||
last = doc.createRange()
|
||||
last.setStart(root.firstChild ?? root, 0)
|
||||
last.setEndAfter(root.lastChild ?? root)
|
||||
if (!rangeIsEmpty(last)) yield last
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate every TTS segment of the document in order without touching any
|
||||
// TTS instance state. blockIndex/markName match what a TTS instance produces
|
||||
// for the same granularity, so callers (e.g. a playback timeline) can
|
||||
// correlate the enumeration with live marks and use each range with from().
|
||||
export function* getSentences(doc, textWalker, nodeFilter, granularity = 'sentence') {
|
||||
let blockIndex = 0
|
||||
for (const range of getBlocks(doc, nodeFilter)) {
|
||||
const lang = getLang(range.commonAncestorContainer)
|
||||
const segmenter = getSegmenter(lang, granularity)
|
||||
for (const [name, segRange] of textWalker(range, segmenter, nodeFilter))
|
||||
yield { blockIndex, markName: name, range: segRange }
|
||||
blockIndex++
|
||||
}
|
||||
}
|
||||
|
||||
class ListIterator {
|
||||
#arr = []
|
||||
#iter
|
||||
#index = -1
|
||||
#f
|
||||
constructor(iter, f = x => x) {
|
||||
this.#iter = iter
|
||||
this.#f = f
|
||||
}
|
||||
current() {
|
||||
if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index])
|
||||
}
|
||||
first() {
|
||||
const newIndex = 0
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
}
|
||||
prev() {
|
||||
const newIndex = this.#index - 1
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
}
|
||||
next() {
|
||||
const newIndex = this.#index + 1
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = this.#iter.next()
|
||||
if (done) break
|
||||
this.#arr.push(value)
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
find(f) {
|
||||
const index = this.#arr.findIndex(x => f(x))
|
||||
if (index > -1) {
|
||||
this.#index = index
|
||||
return this.#f(this.#arr[index])
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = this.#iter.next()
|
||||
if (done) break
|
||||
this.#arr.push(value)
|
||||
if (f(value)) {
|
||||
this.#index = this.#arr.length - 1
|
||||
return this.#f(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TTS {
|
||||
#list
|
||||
#ranges
|
||||
#lastMark
|
||||
#serializer = new XMLSerializer()
|
||||
constructor(doc, textWalker, nodeFilter, highlight, granularity) {
|
||||
this.doc = doc
|
||||
this.highlight = highlight
|
||||
this.#list = new ListIterator(getBlocks(doc, nodeFilter), range => {
|
||||
const { entries, ssml } = getFragmentWithMarks(range, textWalker, nodeFilter, granularity)
|
||||
this.#ranges = new Map(entries)
|
||||
return [ssml, range]
|
||||
})
|
||||
}
|
||||
#getMarkElement(doc, mark) {
|
||||
if (!mark) return null
|
||||
return doc.querySelector(`mark[name="${CSS.escape(mark)}"`)
|
||||
}
|
||||
#speak(doc, getNode) {
|
||||
if (!doc) return
|
||||
if (!getNode) return this.#serializer.serializeToString(doc)
|
||||
const ssml = document.implementation.createDocument(NS.SSML, 'speak')
|
||||
ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true))
|
||||
let node = getNode(ssml)?.previousSibling
|
||||
while (node) {
|
||||
const next = node.previousSibling ?? node.parentNode?.previousSibling
|
||||
node.parentNode.removeChild(node)
|
||||
node = next
|
||||
}
|
||||
const ssmlStr = this.#serializer.serializeToString(ssml)
|
||||
return ssmlStr
|
||||
}
|
||||
start() {
|
||||
this.#lastMark = null
|
||||
const [doc] = this.#list.first() ?? []
|
||||
if (!doc) return this.next()
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
|
||||
}
|
||||
resume() {
|
||||
const [doc] = this.#list.current() ?? []
|
||||
if (!doc) return this.next()
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
|
||||
}
|
||||
prev(paused) {
|
||||
this.#lastMark = null
|
||||
const [doc, range] = this.#list.prev() ?? []
|
||||
if (paused && range) this.highlight(range.cloneRange())
|
||||
return this.#speak(doc)
|
||||
}
|
||||
next(paused) {
|
||||
this.#lastMark = null
|
||||
const [doc, range] = this.#list.next() ?? []
|
||||
if (paused && range) this.highlight(range.cloneRange())
|
||||
return this.#speak(doc)
|
||||
}
|
||||
prevMark(paused) {
|
||||
const marks = Array.from(this.#ranges.keys())
|
||||
if (marks.length === 0) return
|
||||
|
||||
const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1
|
||||
if (currentIndex > 0) {
|
||||
const prevMarkName = marks[currentIndex - 1]
|
||||
const range = this.#ranges.get(prevMarkName)
|
||||
if (range) {
|
||||
this.#lastMark = prevMarkName
|
||||
if (paused) this.highlight(range.cloneRange())
|
||||
|
||||
const [doc] = this.#list.current() ?? []
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, prevMarkName))
|
||||
}
|
||||
} else {
|
||||
const [doc, range] = this.#list.prev() ?? []
|
||||
if (doc && range) {
|
||||
const prevMarks = Array.from(this.#ranges.keys())
|
||||
if (prevMarks.length > 0) {
|
||||
const lastMarkName = prevMarks[prevMarks.length - 1]
|
||||
const lastMarkRange = this.#ranges.get(lastMarkName)
|
||||
if (lastMarkRange) {
|
||||
this.#lastMark = lastMarkName
|
||||
if (paused) this.highlight(lastMarkRange.cloneRange())
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, lastMarkName))
|
||||
}
|
||||
} else {
|
||||
this.#lastMark = null
|
||||
if (paused) this.highlight(range.cloneRange())
|
||||
return this.#speak(doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
nextMark(paused) {
|
||||
const marks = Array.from(this.#ranges.keys())
|
||||
if (marks.length === 0) return
|
||||
|
||||
const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1
|
||||
if (currentIndex >= 0 && currentIndex < marks.length - 1) {
|
||||
const nextMarkName = marks[currentIndex + 1]
|
||||
const range = this.#ranges.get(nextMarkName)
|
||||
if (range) {
|
||||
this.#lastMark = nextMarkName
|
||||
if (paused) this.highlight(range.cloneRange())
|
||||
const [doc] = this.#list.current() ?? []
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, nextMarkName))
|
||||
}
|
||||
} else {
|
||||
const [doc, range] = this.#list.next() ?? []
|
||||
if (doc && range) {
|
||||
const nextMarks = Array.from(this.#ranges.keys())
|
||||
if (nextMarks.length > 0) {
|
||||
const firstMarkName = nextMarks[0]
|
||||
const firstMarkRange = this.#ranges.get(firstMarkName)
|
||||
if (firstMarkRange) {
|
||||
this.#lastMark = firstMarkName
|
||||
if (paused) this.highlight(firstMarkRange.cloneRange())
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, firstMarkName))
|
||||
}
|
||||
} else {
|
||||
this.#lastMark = null
|
||||
if (paused) this.highlight(range.cloneRange())
|
||||
return this.#speak(doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
from(range) {
|
||||
this.#lastMark = null
|
||||
const [doc] = this.#list.find(range_ =>
|
||||
range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0)
|
||||
// Pick the mark whose sentence contains the selection: the last mark
|
||||
// that begins at or before it. Taking the first mark beginning at or
|
||||
// after the selection skipped to the next sentence whenever the
|
||||
// selected word was not its sentence's first word.
|
||||
let mark
|
||||
for (const [name, range_] of this.#ranges.entries()) {
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, range_) < 0) break
|
||||
mark = name
|
||||
}
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark))
|
||||
}
|
||||
getLastRange() {
|
||||
if (this.#lastMark) {
|
||||
const range = this.#ranges.get(this.#lastMark)
|
||||
if (range) return range.cloneRange()
|
||||
}
|
||||
}
|
||||
setMark(mark) {
|
||||
const range = this.#ranges.get(mark)
|
||||
if (range) {
|
||||
this.#lastMark = mark
|
||||
this.highlight(range.cloneRange())
|
||||
return range
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
var r=Uint8Array,a=Uint16Array,e=Int32Array,n=new r([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new r([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),t=new r([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),f=function(r,n){for(var i=new a(31),t=0;t<31;++t)i[t]=n+=1<<r[t-1];var f=new e(i[30]);for(t=1;t<30;++t)for(var o=i[t];o<i[t+1];++o)f[o]=o-i[t]<<5|t;return{b:i,r:f}},o=f(n,2),v=o.b,l=o.r;v[28]=258,l[258]=28;for(var u=f(i,0).b,c=new a(32768),d=0;d<32768;++d){var w=(43690&d)>>1|(21845&d)<<1;w=(61680&(w=(52428&w)>>2|(13107&w)<<2))>>4|(3855&w)<<4,c[d]=((65280&w)>>8|(255&w)<<8)>>1}var b=function(r,e,n){for(var i=r.length,t=0,f=new a(e);t<i;++t)r[t]&&++f[r[t]-1];var o,v=new a(e);for(t=1;t<e;++t)v[t]=v[t-1]+f[t-1]<<1;if(n){o=new a(1<<e);var l=15-e;for(t=0;t<i;++t)if(r[t])for(var u=t<<4|r[t],d=e-r[t],w=v[r[t]-1]++<<d,b=w|(1<<d)-1;w<=b;++w)o[c[w]>>l]=u}else for(o=new a(i),t=0;t<i;++t)r[t]&&(o[t]=c[v[r[t]-1]++]>>15-r[t]);return o},s=new r(288);for(d=0;d<144;++d)s[d]=8;for(d=144;d<256;++d)s[d]=9;for(d=256;d<280;++d)s[d]=7;for(d=280;d<288;++d)s[d]=8;var h=new r(32);for(d=0;d<32;++d)h[d]=5;var y=b(s,9,1),g=b(h,5,1),p=function(r){for(var a=r[0],e=1;e<r.length;++e)r[e]>a&&(a=r[e]);return a},k=function(r,a,e){var n=a/8|0;return(r[n]|r[n+1]<<8)>>(7&a)&e},m=function(r,a){var e=a/8|0;return(r[e]|r[e+1]<<8|r[e+2]<<16)>>(7&a)},x=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],T=function(r,a,e){var n=new Error(a||x[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,T),!e)throw n;return n},E=function(a,e,f,o){var l=a.length,c=o?o.length:0;if(!l||e.f&&!e.l)return f||new r(0);var d=!f,w=d||2!=e.i,s=e.i;d&&(f=new r(3*l));var h=function(a){var e=f.length;if(a>e){var n=new r(Math.max(2*e,a));n.set(f),f=n}},x=e.f||0,E=e.p||0,z=e.b||0,A=e.l,U=e.d,D=e.m,F=e.n,M=8*l;do{if(!A){x=k(a,E,1);var S=k(a,E+1,3);if(E+=3,!S){var I=a[(N=4+((E+7)/8|0))-4]|a[N-3]<<8,O=N+I;if(O>l){s&&T(0);break}w&&h(z+I),f.set(a.subarray(N,O),z),e.b=z+=I,e.p=E=8*O,e.f=x;continue}if(1==S)A=y,U=g,D=9,F=5;else if(2==S){var j=k(a,E,31)+257,q=k(a,E+10,15)+4,B=j+k(a,E+5,31)+1;E+=14;for(var C=new r(B),G=new r(19),H=0;H<q;++H)G[t[H]]=k(a,E+3*H,7);E+=3*q;var J=p(G),K=(1<<J)-1,L=b(G,J,1);for(H=0;H<B;){var N,P=L[k(a,E,K)];if(E+=15&P,(N=P>>4)<16)C[H++]=N;else{var Q=0,R=0;for(16==N?(R=3+k(a,E,3),E+=2,Q=C[H-1]):17==N?(R=3+k(a,E,7),E+=3):18==N&&(R=11+k(a,E,127),E+=7);R--;)C[H++]=Q}}var V=C.subarray(0,j),W=C.subarray(j);D=p(V),F=p(W),A=b(V,D,1),U=b(W,F,1)}else T(1);if(E>M){s&&T(0);break}}w&&h(z+131072);for(var X=(1<<D)-1,Y=(1<<F)-1,Z=E;;Z=E){var $=(Q=A[m(a,E)&X])>>4;if((E+=15&Q)>M){s&&T(0);break}if(Q||T(2),$<256)f[z++]=$;else{if(256==$){Z=E,A=null;break}var _=$-254;if($>264){var rr=n[H=$-257];_=k(a,E,(1<<rr)-1)+v[H],E+=rr}var ar=U[m(a,E)&Y],er=ar>>4;ar||T(3),E+=15&ar;W=u[er];if(er>3){rr=i[er];W+=m(a,E)&(1<<rr)-1,E+=rr}if(E>M){s&&T(0);break}w&&h(z+131072);var nr=z+_;if(z<W){var ir=c-W,tr=Math.min(W,nr);for(ir+z<0&&T(3);z<tr;++z)f[z]=o[ir+z]}for(;z<nr;++z)f[z]=f[z-W]}}e.l=A,e.p=Z,e.b=z,e.f=x,A&&(x=1,e.m=D,e.d=U,e.n=F)}while(!x);return z!=f.length&&d?function(a,e,n){return(null==n||n>a.length)&&(n=a.length),new r(a.subarray(e,n))}(f,0,z):f.subarray(0,z)},z=new r(0);function A(r,a){return E(r.subarray((e=r,n=a&&a.dictionary,(8!=(15&e[0])||e[0]>>4>7||(e[0]<<8|e[1])%31)&&T(6,"invalid zlib data"),(e[1]>>5&1)==+!n&&T(6,"invalid zlib data: "+(32&e[1]?"need":"unexpected")+" dictionary"),2+(e[1]>>3&4)),-4),{i:2},a&&a.out,a&&a.dictionary);var e,n}var U="undefined"!=typeof TextDecoder&&new TextDecoder;try{U.decode(z,{stream:!0})}catch(r){}export{A as unzlibSync};
|
||||
File diff suppressed because one or more lines are too long
+704
@@ -0,0 +1,704 @@
|
||||
import * as CFI from './epubcfi.js'
|
||||
import { TOCProgress, SectionProgress, PageProgress } from './progress.js'
|
||||
import { Overlayer } from './overlayer.js'
|
||||
import { textWalker } from './text-walker.js'
|
||||
|
||||
const SEARCH_PREFIX = 'foliate-search:'
|
||||
|
||||
const NOTE_PREFIX = 'foliate-note:'
|
||||
|
||||
const isZip = async file => {
|
||||
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
|
||||
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
|
||||
}
|
||||
|
||||
const isPDF = async file => {
|
||||
const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())
|
||||
return arr[0] === 0x25
|
||||
&& arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46
|
||||
&& arr[4] === 0x2d
|
||||
}
|
||||
|
||||
const isCBZ = ({ name, type }) =>
|
||||
type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')
|
||||
|
||||
const isFB2 = ({ name, type }) =>
|
||||
type === 'application/x-fictionbook+xml' || name.endsWith('.fb2')
|
||||
|
||||
const isFBZ = ({ name, type }) =>
|
||||
type === 'application/x-zip-compressed-fb2'
|
||||
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
|
||||
|
||||
const makeZipLoader = async file => {
|
||||
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =
|
||||
await import('./vendor/zip.js')
|
||||
configure({ useWebWorkers: false })
|
||||
const reader = new ZipReader(new BlobReader(file))
|
||||
const entries = await reader.getEntries()
|
||||
const map = new Map(entries.map(entry => [entry.filename, entry]))
|
||||
const load = f => (name, ...args) =>
|
||||
map.has(name) ? f(map.get(name), ...args) : null
|
||||
const loadText = load(entry => entry.getData(new TextWriter()))
|
||||
const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))
|
||||
const getSize = name => map.get(name)?.uncompressedSize ?? 0
|
||||
return { entries, loadText, loadBlob, getSize }
|
||||
}
|
||||
|
||||
const getFileEntries = async entry => entry.isFile ? entry
|
||||
: (await Promise.all(Array.from(
|
||||
await new Promise((resolve, reject) => entry.createReader()
|
||||
.readEntries(entries => resolve(entries), error => reject(error))),
|
||||
getFileEntries))).flat()
|
||||
|
||||
const makeDirectoryLoader = async entry => {
|
||||
const entries = await getFileEntries(entry)
|
||||
const files = await Promise.all(
|
||||
entries.map(entry => new Promise((resolve, reject) =>
|
||||
entry.file(file => resolve([file, entry.fullPath]),
|
||||
error => reject(error)))))
|
||||
const map = new Map(files.map(([file, path]) =>
|
||||
[path.replace(entry.fullPath + '/', ''), file]))
|
||||
const decoder = new TextDecoder()
|
||||
const decode = x => x ? decoder.decode(x) : null
|
||||
const getBuffer = name => map.get(name)?.arrayBuffer() ?? null
|
||||
const loadText = async name => decode(await getBuffer(name))
|
||||
const loadBlob = name => map.get(name)
|
||||
const getSize = name => map.get(name)?.size ?? 0
|
||||
return { loadText, loadBlob, getSize }
|
||||
}
|
||||
|
||||
export class ResponseError extends Error {}
|
||||
export class NotFoundError extends Error {}
|
||||
export class UnsupportedTypeError extends Error {}
|
||||
|
||||
const fetchFile = async url => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new ResponseError(
|
||||
`${res.status} ${res.statusText}`, { cause: res })
|
||||
return new File([await res.blob()], new URL(res.url).pathname)
|
||||
}
|
||||
|
||||
export const makeBook = async file => {
|
||||
if (typeof file === 'string') file = await fetchFile(file)
|
||||
let book
|
||||
if (file.isDirectory) {
|
||||
const loader = await makeDirectoryLoader(file)
|
||||
const { EPUB } = await import('./epub.js')
|
||||
book = await new EPUB(loader).init()
|
||||
}
|
||||
else if (!file.size) throw new NotFoundError('File not found')
|
||||
else if (await isZip(file)) {
|
||||
const loader = await makeZipLoader(file)
|
||||
if (isCBZ(file)) {
|
||||
const { makeComicBook } = await import('./comic-book.js')
|
||||
book = makeComicBook(loader, file)
|
||||
}
|
||||
else if (isFBZ(file)) {
|
||||
const { makeFB2 } = await import('./fb2.js')
|
||||
const { entries } = loader
|
||||
const entry = entries.find(entry => entry.filename.endsWith('.fb2'))
|
||||
const blob = await loader.loadBlob((entry ?? entries[0]).filename)
|
||||
book = await makeFB2(blob)
|
||||
}
|
||||
else {
|
||||
const { EPUB } = await import('./epub.js')
|
||||
book = await new EPUB(loader).init()
|
||||
}
|
||||
}
|
||||
else if (await isPDF(file)) {
|
||||
const { makePDF } = await import('./pdf.js')
|
||||
book = await makePDF(file)
|
||||
}
|
||||
else {
|
||||
const { isMOBI, MOBI } = await import('./mobi.js')
|
||||
if (await isMOBI(file)) {
|
||||
const fflate = await import('./vendor/fflate.js')
|
||||
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file)
|
||||
}
|
||||
else if (isFB2(file)) {
|
||||
const { makeFB2 } = await import('./fb2.js')
|
||||
book = await makeFB2(file)
|
||||
}
|
||||
}
|
||||
if (!book) throw new UnsupportedTypeError('File type not supported')
|
||||
return book
|
||||
}
|
||||
|
||||
class CursorAutohider {
|
||||
#timeout
|
||||
#el
|
||||
#check
|
||||
#state
|
||||
constructor(el, check, state = {}) {
|
||||
this.#el = el
|
||||
this.#check = check
|
||||
this.#state = state
|
||||
if (this.#state.hidden) this.hide()
|
||||
this.#el.addEventListener('mousemove', ({ screenX, screenY }) => {
|
||||
// check if it actually moved
|
||||
if (screenX === this.#state.x && screenY === this.#state.y) return
|
||||
this.#state.x = screenX, this.#state.y = screenY
|
||||
this.show()
|
||||
if (this.#timeout) clearTimeout(this.#timeout)
|
||||
if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000)
|
||||
}, false)
|
||||
}
|
||||
cloneFor(el) {
|
||||
return new CursorAutohider(el, this.#check, this.#state)
|
||||
}
|
||||
#hasSelection() {
|
||||
const selection = this.#el.ownerDocument?.getSelection()
|
||||
return selection ? !selection.isCollapsed : false
|
||||
}
|
||||
hide() {
|
||||
// The pointer is what the reader aims a selection with, so leave it
|
||||
// alone while one stands: a paused drag, or a double-click word
|
||||
// select (which fires no mousemove at all), would otherwise blank it.
|
||||
if (this.#hasSelection()) return
|
||||
this.#el.style.cursor = 'none'
|
||||
this.#state.hidden = true
|
||||
}
|
||||
show() {
|
||||
this.#el.style.removeProperty('cursor')
|
||||
this.#state.hidden = false
|
||||
}
|
||||
}
|
||||
|
||||
class History extends EventTarget {
|
||||
#arr = []
|
||||
#index = -1
|
||||
pushState(x) {
|
||||
const last = this.#arr[this.#index]
|
||||
if (last === x || last?.fraction && last.fraction === x.fraction) return
|
||||
this.#arr[++this.#index] = x
|
||||
this.#arr.length = this.#index + 1
|
||||
this.dispatchEvent(new Event('index-change'))
|
||||
}
|
||||
replaceState(x) {
|
||||
const index = this.#index
|
||||
this.#arr[index] = x
|
||||
}
|
||||
back() {
|
||||
const index = this.#index
|
||||
if (index <= 0) return
|
||||
const detail = { state: this.#arr[index - 1] }
|
||||
this.#index = index - 1
|
||||
this.dispatchEvent(new CustomEvent('popstate', { detail }))
|
||||
this.dispatchEvent(new Event('index-change'))
|
||||
}
|
||||
forward() {
|
||||
const index = this.#index
|
||||
if (index >= this.#arr.length - 1) return
|
||||
const detail = { state: this.#arr[index + 1] }
|
||||
this.#index = index + 1
|
||||
this.dispatchEvent(new CustomEvent('popstate', { detail }))
|
||||
this.dispatchEvent(new Event('index-change'))
|
||||
}
|
||||
get canGoBack() {
|
||||
return this.#index > 0
|
||||
}
|
||||
get canGoForward() {
|
||||
return this.#index < this.#arr.length - 1
|
||||
}
|
||||
clear() {
|
||||
this.#arr = []
|
||||
this.#index = -1
|
||||
}
|
||||
}
|
||||
|
||||
const languageInfo = lang => {
|
||||
if (!lang) return {}
|
||||
try {
|
||||
const canonical = Intl.getCanonicalLocales(lang)[0]
|
||||
const locale = new Intl.Locale(canonical)
|
||||
const isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
|
||||
const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction
|
||||
return { canonical, locale, isCJK, direction }
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export class View extends HTMLElement {
|
||||
#root = this.attachShadow({ mode: 'open' })
|
||||
#sectionProgress
|
||||
#tocProgress
|
||||
#pageProgress
|
||||
#cfiProgress
|
||||
#searchResults = new Map()
|
||||
#cursorAutohider = new CursorAutohider(this, () =>
|
||||
this.hasAttribute('autohide-cursor'))
|
||||
isFixedLayout = false
|
||||
lastLocation
|
||||
history = new History()
|
||||
constructor() {
|
||||
super()
|
||||
this.history.addEventListener('popstate', ({ detail }) => {
|
||||
const resolved = this.resolveNavigation(detail.state)
|
||||
this.renderer.goTo(resolved)
|
||||
})
|
||||
}
|
||||
async open(book) {
|
||||
if (typeof book === 'string'
|
||||
|| typeof book.arrayBuffer === 'function'
|
||||
|| book.isDirectory) book = await makeBook(book)
|
||||
this.book = book
|
||||
this.language = languageInfo(book.metadata?.language)
|
||||
|
||||
if (book.splitTOCHref && book.getTOCFragment) {
|
||||
const ids = book.sections.map(s => s.id)
|
||||
this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600)
|
||||
const splitHref = book.splitTOCHref.bind(book)
|
||||
const getFragment = book.getTOCFragment.bind(book)
|
||||
this.#tocProgress = new TOCProgress()
|
||||
await this.#tocProgress.init({
|
||||
toc: book.toc ?? [], ids, splitHref, getFragment })
|
||||
this.#pageProgress = new TOCProgress()
|
||||
await this.#pageProgress.init({
|
||||
toc: book.pageList ?? [], ids, splitHref, getFragment })
|
||||
}
|
||||
this.#cfiProgress = new PageProgress(book, this.resolveNavigation.bind(this))
|
||||
|
||||
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
|
||||
if (this.isFixedLayout) {
|
||||
await import('./fixed-layout.js')
|
||||
this.renderer = document.createElement('foliate-fxl')
|
||||
} else {
|
||||
await import('./paginator.js')
|
||||
this.renderer = document.createElement('foliate-paginator')
|
||||
}
|
||||
this.renderer.setAttribute('exportparts', 'head,foot,filter,container')
|
||||
this.renderer.addEventListener('load', e => this.#onLoad(e.detail))
|
||||
this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail))
|
||||
this.renderer.addEventListener('create-overlayer', e =>
|
||||
e.detail.attach(this.#createOverlayer(e.detail)))
|
||||
this.renderer.open(book)
|
||||
this.#root.append(this.renderer)
|
||||
|
||||
if (book.sections.some(section => section.mediaOverlay)) {
|
||||
const activeClass = book.media.activeClass
|
||||
const playbackActiveClass = book.media.playbackActiveClass
|
||||
this.mediaOverlay = book.getMediaOverlay()
|
||||
let lastActive
|
||||
this.mediaOverlay.addEventListener('highlight', e => {
|
||||
const resolved = this.resolveNavigation(e.detail.text)
|
||||
this.renderer.goTo(resolved)
|
||||
.then(() => {
|
||||
const { doc } = this.renderer.getContents()
|
||||
.find(x => x.index = resolved.index)
|
||||
const el = resolved.anchor(doc)
|
||||
el.classList.add(activeClass)
|
||||
if (playbackActiveClass) el.ownerDocument
|
||||
.documentElement.classList.add(playbackActiveClass)
|
||||
lastActive = new WeakRef(el)
|
||||
})
|
||||
})
|
||||
this.mediaOverlay.addEventListener('unhighlight', () => {
|
||||
const el = lastActive?.deref()
|
||||
if (el) {
|
||||
el.classList.remove(activeClass)
|
||||
if (playbackActiveClass) el.ownerDocument
|
||||
.documentElement.classList.remove(playbackActiveClass)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this.renderer?.destroy()
|
||||
this.renderer?.remove()
|
||||
this.#sectionProgress = null
|
||||
this.#tocProgress = null
|
||||
this.#pageProgress = null
|
||||
this.#cfiProgress = null
|
||||
this.#searchResults = new Map()
|
||||
this.lastLocation = null
|
||||
this.history.clear()
|
||||
this.tts = null
|
||||
this.mediaOverlay = null
|
||||
}
|
||||
goToTextStart() {
|
||||
return this.goTo(this.book.landmarks
|
||||
?.find(m => m.type.includes('bodymatter') || m.type.includes('text'))
|
||||
?.href ?? this.book.sections.findIndex(s => s.linear !== 'no'))
|
||||
}
|
||||
async init({ lastLocation, showTextStart }) {
|
||||
const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null
|
||||
if (resolved) {
|
||||
await this.renderer.goTo(resolved)
|
||||
this.history.pushState(lastLocation)
|
||||
}
|
||||
else if (showTextStart) await this.goToTextStart()
|
||||
else {
|
||||
this.history.pushState(0)
|
||||
await this.next()
|
||||
}
|
||||
}
|
||||
#emit(name, detail, cancelable) {
|
||||
return this.dispatchEvent(new CustomEvent(name, { detail, cancelable }))
|
||||
}
|
||||
#onRelocate({ reason, range, index, fraction, size }) {
|
||||
const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}
|
||||
const tocItem = this.#tocProgress?.getProgress(index, range)
|
||||
const pageItem = this.#pageProgress?.getProgress(index, range)
|
||||
const cfi = this.getCFI(index, range)
|
||||
this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }
|
||||
if (reason === 'snap' || reason === 'page' || reason === 'scroll')
|
||||
this.history.replaceState(cfi)
|
||||
this.#emit('relocate', this.lastLocation)
|
||||
}
|
||||
#onLoad({ doc, index }) {
|
||||
// set language and dir if not already set
|
||||
doc.documentElement.lang ||= this.language.canonical ?? ''
|
||||
if (!this.language.isCJK)
|
||||
doc.documentElement.dir ||= this.language.direction ?? ''
|
||||
|
||||
this.#handleLinks(doc, index)
|
||||
this.#cursorAutohider.cloneFor(doc.documentElement)
|
||||
|
||||
this.#emit('load', { doc, index })
|
||||
}
|
||||
#handleLinks(doc, index) {
|
||||
const { book } = this
|
||||
const section = book.sections[index]
|
||||
doc.addEventListener('click', e => {
|
||||
const a = e.target.closest('a[href]')
|
||||
if (!a) return
|
||||
e.preventDefault()
|
||||
const href_ = a.getAttribute('href')
|
||||
const href = section?.resolveHref?.(href_) ?? href_
|
||||
if (book?.isExternal?.(href))
|
||||
Promise.resolve(this.#emit('external-link', { a, href }, true))
|
||||
.then(x => x ? globalThis.open(href, '_blank') : null)
|
||||
.catch(e => console.error(e))
|
||||
else {
|
||||
let internalHref = href
|
||||
if (!book.resolveHref(href)) {
|
||||
const hashIndex = href_.indexOf('#')
|
||||
if (hashIndex >= 0) {
|
||||
const hash = href_.slice(hashIndex)
|
||||
internalHref = section?.resolveHref?.(hash) ?? href
|
||||
}
|
||||
}
|
||||
Promise.resolve(this.#emit('link', { a, href: internalHref }, true))
|
||||
.then(x => x ? this.goTo(internalHref) : null)
|
||||
.catch(e => console.error(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
async addAnnotation(annotation, remove) {
|
||||
const { value } = annotation
|
||||
if (value.startsWith(SEARCH_PREFIX)) {
|
||||
const cfi = value.replace(SEARCH_PREFIX, '')
|
||||
const { index, anchor } = await this.resolveNavigation(cfi)
|
||||
const obj = this.#getOverlayer(index)
|
||||
if (obj) {
|
||||
const { overlayer, doc } = obj
|
||||
if (remove) {
|
||||
overlayer.remove(value)
|
||||
return
|
||||
}
|
||||
const range = doc ? anchor(doc) : anchor
|
||||
if (range) overlayer.add(value, range, Overlayer.outline)
|
||||
}
|
||||
return
|
||||
} else if (value.startsWith(NOTE_PREFIX)) {
|
||||
const cfi = value.replace(NOTE_PREFIX, '')
|
||||
const { index, anchor } = await this.resolveNavigation(cfi)
|
||||
const obj = this.#getOverlayer(index)
|
||||
if (obj) {
|
||||
const { overlayer, doc } = obj
|
||||
if (remove) {
|
||||
overlayer.remove(value)
|
||||
return
|
||||
}
|
||||
const range = doc ? anchor(doc) : anchor
|
||||
if (range) {
|
||||
const draw = (func, opts) => overlayer.add(value, range, func, opts)
|
||||
this.#emit('draw-annotation', { draw, annotation, doc, range })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const { index, anchor } = await this.resolveNavigation(value)
|
||||
const obj = this.#getOverlayer(index)
|
||||
if (obj) {
|
||||
const { overlayer, doc } = obj
|
||||
overlayer.remove(value)
|
||||
if (!remove) {
|
||||
const range = doc ? anchor(doc) : anchor
|
||||
if (range) {
|
||||
const draw = (func, opts) => overlayer.add(value, range, func, opts)
|
||||
this.#emit('draw-annotation', { draw, annotation, doc, range })
|
||||
}
|
||||
}
|
||||
}
|
||||
const label = this.#tocProgress?.getProgress(index)?.label ?? ''
|
||||
return { index, label }
|
||||
}
|
||||
deleteAnnotation(annotation) {
|
||||
return this.addAnnotation(annotation, true)
|
||||
}
|
||||
#getOverlayer(index) {
|
||||
return this.renderer.getContents()
|
||||
.find(x => x.index === index && x.overlayer)
|
||||
}
|
||||
#createOverlayer({ doc, index }) {
|
||||
const overlayer = new Overlayer(doc)
|
||||
doc.addEventListener('click', e => {
|
||||
const [value, range, rect] = overlayer.hitTest(e)
|
||||
if (value && !value.startsWith(SEARCH_PREFIX)) {
|
||||
this.#emit('show-annotation', { value, index, range, rect })
|
||||
}
|
||||
}, false)
|
||||
|
||||
let lastHitTestTime = 0
|
||||
const THROTTLE_MS = 200
|
||||
const isAndroid = /Android/i.test(navigator.userAgent)
|
||||
|
||||
doc.addEventListener('mousemove', (e) => {
|
||||
if (isAndroid) return
|
||||
const now = performance.now()
|
||||
if (now - lastHitTestTime < THROTTLE_MS) return
|
||||
lastHitTestTime = now
|
||||
const [value] = overlayer.hitTest(e)
|
||||
if (value && !value.startsWith(SEARCH_PREFIX)) {
|
||||
doc.body.style.cursor = 'pointer'
|
||||
} else {
|
||||
doc.body.style.cursor = ''
|
||||
}
|
||||
})
|
||||
|
||||
const list = this.#searchResults.get(index)
|
||||
if (list) for (const item of list) this.addAnnotation(item)
|
||||
|
||||
this.#emit('create-overlay', { index })
|
||||
return overlayer
|
||||
}
|
||||
async showAnnotation(annotation) {
|
||||
const { value } = annotation
|
||||
const resolved = await this.goTo(value)
|
||||
if (resolved) {
|
||||
const { index, anchor } = resolved
|
||||
const { doc } = this.#getOverlayer(index)
|
||||
const range = anchor(doc)
|
||||
this.#emit('show-annotation', { value, index, range })
|
||||
}
|
||||
}
|
||||
getCFI(index, range) {
|
||||
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
|
||||
if (!range) return baseCFI
|
||||
return CFI.joinIndir(baseCFI, CFI.fromRange(range))
|
||||
}
|
||||
resolveCFI(cfi) {
|
||||
if (this.book.resolveCFI)
|
||||
return this.book.resolveCFI(cfi)
|
||||
else {
|
||||
const parts = CFI.parse(cfi)
|
||||
const index = CFI.fake.toIndex((parts.parent ?? parts).shift())
|
||||
const anchor = doc => CFI.toRange(doc, parts)
|
||||
return { index, anchor }
|
||||
}
|
||||
}
|
||||
resolveNavigation(target) {
|
||||
try {
|
||||
if (typeof target === 'number') return { index: target }
|
||||
if (typeof target.fraction === 'number') {
|
||||
const [index, anchor] = this.#sectionProgress.getSection(target.fraction)
|
||||
return { index, anchor }
|
||||
}
|
||||
if (CFI.isCFI.test(target)) return this.resolveCFI(target)
|
||||
return this.book.resolveHref(target)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error(`Could not resolve target ${target}`)
|
||||
}
|
||||
}
|
||||
async goTo(target) {
|
||||
const resolved = this.resolveNavigation(target)
|
||||
try {
|
||||
await this.renderer.goTo(resolved)
|
||||
this.history.pushState(target)
|
||||
return resolved
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
console.error(`Could not go to ${target}`)
|
||||
}
|
||||
}
|
||||
async goToFraction(frac) {
|
||||
const [index, anchor] = this.#sectionProgress.getSection(frac)
|
||||
await this.renderer.goTo({ index, anchor })
|
||||
this.history.pushState({ fraction: frac })
|
||||
}
|
||||
async select(target) {
|
||||
try {
|
||||
const obj = await this.resolveNavigation(target)
|
||||
await this.renderer.goTo({ ...obj, select: true })
|
||||
this.history.pushState(target)
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
console.error(`Could not go to ${target}`)
|
||||
}
|
||||
}
|
||||
deselect() {
|
||||
for (const { doc } of this.renderer.getContents())
|
||||
doc.defaultView.getSelection().removeAllRanges()
|
||||
}
|
||||
getSectionFractions() {
|
||||
return (this.#sectionProgress?.sectionFractions ?? [])
|
||||
.map(x => x + Number.EPSILON)
|
||||
}
|
||||
getProgressOf(index, range) {
|
||||
const tocItem = this.#tocProgress?.getProgress(index, range)
|
||||
const pageItem = this.#pageProgress?.getProgress(index, range)
|
||||
return { tocItem, pageItem }
|
||||
}
|
||||
async getCFIProgress(cfi) {
|
||||
const progress = await this.#cfiProgress?.getProgress(cfi)
|
||||
if (!progress || progress.index === -1) return null
|
||||
return this.#sectionProgress?.getProgress(progress.index, progress.fraction)
|
||||
}
|
||||
async getTOCItemOf(target) {
|
||||
try {
|
||||
const { index, anchor } = await this.resolveNavigation(target)
|
||||
const doc = await this.book.sections[index].createDocument()
|
||||
const frag = anchor(doc)
|
||||
const isRange = frag instanceof Range
|
||||
const range = isRange ? frag : doc.createRange()
|
||||
if (!isRange) range.selectNodeContents(frag)
|
||||
return this.#tocProgress?.getProgress(index, range)
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
console.error(`Could not get ${target}`)
|
||||
}
|
||||
}
|
||||
async prev(distance) {
|
||||
await this.renderer.prev(distance)
|
||||
}
|
||||
async next(distance) {
|
||||
await this.renderer.next(distance)
|
||||
}
|
||||
async pan(dx, dy) {
|
||||
await this.renderer.pan(dx, dy)
|
||||
}
|
||||
isOverflowX() {
|
||||
return this.renderer.isOverflowX
|
||||
}
|
||||
isOverflowY() {
|
||||
return this.renderer.isOverflowY
|
||||
}
|
||||
goLeft() {
|
||||
return this.book.dir === 'rtl' ? this.next() : this.prev()
|
||||
}
|
||||
goRight() {
|
||||
return this.book.dir === 'rtl' ? this.prev() : this.next()
|
||||
}
|
||||
// A matcher result carries a primary `range` (nav/excerpt anchor) and, for
|
||||
// nearby-words, per-word `subRanges` to highlight each matched word.
|
||||
#toSearchMatch(index, { range, excerpt, subRanges }) {
|
||||
const cfi = this.getCFI(index, range)
|
||||
if (subRanges?.length)
|
||||
return { cfi, cfis: subRanges.map(r => this.getCFI(index, r)), excerpt }
|
||||
return { cfi, excerpt }
|
||||
}
|
||||
async * #searchSection(matcher, query, index) {
|
||||
const doc = await this.book.sections[index].createDocument()
|
||||
for (const match of matcher(doc, query))
|
||||
yield this.#toSearchMatch(index, match)
|
||||
}
|
||||
async * #searchBook(matcher, query) {
|
||||
const { sections } = this.book
|
||||
for (const [index, { createDocument }] of sections.entries()) {
|
||||
if (!createDocument) continue
|
||||
const doc = await createDocument()
|
||||
const subitems = Array.from(matcher(doc, query), match => this.#toSearchMatch(index, match))
|
||||
const progress = (index + 1) / sections.length
|
||||
yield { progress }
|
||||
if (subitems.length) yield { index, subitems }
|
||||
}
|
||||
}
|
||||
async * search(opts) {
|
||||
this.clearSearch()
|
||||
const { searchMatcher } = await import('./search.js')
|
||||
const { sections } = this.book
|
||||
const { query, index, results } = opts
|
||||
const matcher = searchMatcher(textWalker,
|
||||
{ defaultLocale: this.language, ...opts })
|
||||
|
||||
const iter = results?.length
|
||||
? (async function* () {
|
||||
for (const result of results) {
|
||||
if (result.subitems) {
|
||||
const progress = (result.index + 1) / sections.length
|
||||
yield { progress }
|
||||
yield { index: result.index, subitems: result.subitems }
|
||||
} else {
|
||||
yield { cfi: result.cfi, cfis: result.cfis, excerpt: result.excerpt }
|
||||
}
|
||||
}
|
||||
})()
|
||||
: index != null
|
||||
? this.#searchSection(matcher, query, index)
|
||||
: this.#searchBook(matcher, query)
|
||||
|
||||
const list = []
|
||||
const seen = new Set()
|
||||
this.#searchResults.set(index, list)
|
||||
// Add one annotation per unique CFI (a nearby-words match carries several
|
||||
// via `cfis`); dedupe so overlapping CFIs don't collide in #searchResults.
|
||||
const addHighlights = (cfis, sink, sinkSeen) => {
|
||||
for (const cfi of cfis) {
|
||||
if (sinkSeen.has(cfi)) continue
|
||||
sinkSeen.add(cfi)
|
||||
const item = { value: SEARCH_PREFIX + cfi }
|
||||
sink.push(item)
|
||||
this.addAnnotation(item)
|
||||
}
|
||||
}
|
||||
|
||||
for await (const result of iter) {
|
||||
if (result.subitems){
|
||||
const sectionList = []
|
||||
const sectionSeen = new Set()
|
||||
for (const item of result.subitems)
|
||||
addHighlights(item.cfis ?? [item.cfi], sectionList, sectionSeen)
|
||||
this.#searchResults.set(result.index, sectionList)
|
||||
yield {
|
||||
index: result.index,
|
||||
label: this.#tocProgress?.getProgress(result.index)?.label ?? '',
|
||||
subitems: result.subitems,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (result.cfi) addHighlights(result.cfis ?? [result.cfi], list, seen)
|
||||
yield result
|
||||
}
|
||||
}
|
||||
yield 'done'
|
||||
}
|
||||
clearSearch() {
|
||||
for (const list of this.#searchResults.values())
|
||||
for (const item of list) this.deleteAnnotation(item)
|
||||
this.#searchResults.clear()
|
||||
}
|
||||
async initTTS(granularity = 'word', nodeFilter, highlighter) {
|
||||
const contents = this.renderer.getContents()
|
||||
const primaryIndex = this.renderer.primaryIndex
|
||||
const primary = contents.find(x => x.index === primaryIndex) ?? contents[0]
|
||||
const doc = primary?.doc
|
||||
if (!doc) return
|
||||
if (this.tts && this.tts.doc === doc) return
|
||||
const { TTS } = await import('./tts.js')
|
||||
this.tts = new TTS(doc, textWalker, nodeFilter, highlighter || (range =>
|
||||
this.renderer.scrollToAnchor(range, true)), granularity)
|
||||
}
|
||||
startMediaOverlay() {
|
||||
const contents = this.renderer.getContents()
|
||||
const primaryIndex = this.renderer.primaryIndex
|
||||
const primary = contents.find(x => x.index === primaryIndex) ?? contents[0]
|
||||
const { index } = primary ?? {}
|
||||
return this.mediaOverlay.start(index)
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('foliate-view', View)
|
||||
@@ -1,28 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
|
||||
import { CollapsibleText } from '$lib/components/ui/collapsible-text/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import BookImage from '$lib/components/view/book-image.svelte';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import BookCover from '$lib/components/view/book-cover.svelte';
|
||||
import type { BookFile } from '$lib/schema';
|
||||
import {
|
||||
Album,
|
||||
BookOpenCheck,
|
||||
BookOpenText,
|
||||
CalendarDays,
|
||||
Download,
|
||||
Link,
|
||||
NotebookPen,
|
||||
NotebookText,
|
||||
Pencil,
|
||||
PlusIcon,
|
||||
Tags,
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
import { formatFileSize, getFileType } from '$lib/utils.js';
|
||||
import { describeIdentifier, formatFileSize, getFileType, sortIdentifiers } from '$lib/utils.js';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
|
||||
import { getLibraryState } from '$lib/state/library.svelte.js';
|
||||
@@ -31,10 +27,13 @@
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let book = $state(data.book);
|
||||
// Seeded once, then kept in sync by the effect below — untrack says so
|
||||
// explicitly rather than capturing the initial value and warning about it.
|
||||
let book = $state(untrack(() => data.book));
|
||||
|
||||
$effect(() => {
|
||||
book = data.book;
|
||||
@@ -47,355 +46,421 @@
|
||||
let deleteFiles = $state(true);
|
||||
let fileDeleteDialogOpen = $state(false);
|
||||
let fileToDelete = $state<number>();
|
||||
let createShelfDialogOpen = $state(false)
|
||||
let createShelfDialogOpen = $state(false);
|
||||
|
||||
const percent = $derived(
|
||||
book.progress?.percentage ? Math.round(book.progress.percentage * 100) : 0
|
||||
);
|
||||
|
||||
// The primary action states what it will actually do.
|
||||
const readLabel = $derived(
|
||||
book.progress?.completed
|
||||
? 'Read again'
|
||||
: percent > 0
|
||||
? `Continue reading · ${percent}%`
|
||||
: 'Read'
|
||||
);
|
||||
|
||||
// window.open is outside the lint rule's reach, but the paths need resolving
|
||||
// just the same — they would break under a non-empty base path.
|
||||
function openBookInReader(file: BookFile) {
|
||||
const params = { bookId: String(book.id), fileId: String(file.id) };
|
||||
|
||||
if (getFileType(file.filename) === 'EPUB')
|
||||
window.open(`/book/${book.id}/read/epub/${file.id}`, '_blank', 'noopener,noreferrer');
|
||||
window.open(
|
||||
resolve('/(root)/(library)/book/[bookId]/read/epub/[fileId]', params),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
|
||||
if (getFileType(file.filename) === 'PDF')
|
||||
window.open(`/book/${book.id}/read/pdf/${file.id}`, '_blank', 'noopener,noreferrer');
|
||||
window.open(
|
||||
resolve('/(root)/(library)/book/[bookId]/read/pdf/[fileId]', params),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
}
|
||||
|
||||
// On the band, the accent is the ground — invert the buttons against it.
|
||||
const bandPrimary =
|
||||
'inline-flex items-center gap-2 rounded-lg bg-primary-foreground px-4 py-2 text-sm font-semibold text-primary tabular-nums transition-colors hover:bg-primary-foreground/90';
|
||||
const bandGhost =
|
||||
'inline-flex items-center gap-2 rounded-lg border border-primary-foreground/35 px-4 py-2 text-sm font-medium transition-colors hover:bg-primary-foreground/10';
|
||||
</script>
|
||||
|
||||
<div class="mt-4 flex flex-1 flex-col items-center">
|
||||
<div class="ml-8 grid max-w-[900px] grid-cols-[minmax(250px,1fr)_2.5fr] gap-8">
|
||||
<!-- Cover image and action buttons -->
|
||||
<aside class="flex flex-col gap-4">
|
||||
<a href="/book/{book.id}" class="w-full rounded shadow-lg drop-shadow-lg">
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="h-full w-full overflow-hidden rounded object-cover"
|
||||
/>
|
||||
<div class="flex h-full flex-col overflow-y-auto">
|
||||
<!-- Jacket band: the accent carries the identity, the cover overlaps its edge -->
|
||||
<!--
|
||||
A contained card, not a full-bleed banner. Breaking out with -mx-4 left it
|
||||
inset on one side and bleeding off the other, and the negative top margin
|
||||
was clipped by the layout's overflow-hidden. rounded-lg is exactly
|
||||
var(--radius), so the Corners setting reaches it.
|
||||
-->
|
||||
<header class="rounded-lg bg-primary px-6 pt-10 pb-6 text-primary-foreground md:px-10">
|
||||
<!-- items-start so the title sits on the cover's top edge; the cover's
|
||||
negative bottom margin still carries the overlap below the band -->
|
||||
<div class="mx-auto flex max-w-5xl items-start gap-6">
|
||||
<!-- BookCover draws its own progress bar, clipped to the cover's radius -->
|
||||
<BookCover {book} height={260} class="-mb-20 drop-shadow-2xl" />
|
||||
|
||||
{#if book?.progress?.percentage}
|
||||
<Progress
|
||||
value={book?.progress.percentage}
|
||||
max={1}
|
||||
color={book?.progress.completed ? 'bg-green-600' : 'bg-yellow-500'}
|
||||
class="mt-[-8px] h-2 rounded {book?.progress.completed
|
||||
? '[&>div]:bg-green-600'
|
||||
: '[&>div]:bg-yellow-500'}"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="font-serif text-3xl leading-tight tracking-tight text-balance">
|
||||
{book.title}
|
||||
</h1>
|
||||
|
||||
{#if book.subtitle}
|
||||
<p class="mt-1 font-serif text-lg text-primary-foreground/75 italic">{book.subtitle}</p>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
<!-- If there are multiple files to choose from -->
|
||||
{#if book.files.length > 1}
|
||||
<!-- Read button -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="{buttonVariants({ variant: 'accent' })} drop-shadow-lg">
|
||||
<BookOpenText />
|
||||
Read
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each book.files as file}
|
||||
<DropdownMenu.Item onclick={() => openBookInReader(file)}
|
||||
>{getFileType(file.filename)}</DropdownMenu.Item
|
||||
>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Download Button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="w-full {buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} drop-shadow-lg"
|
||||
>
|
||||
<Download />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each book.files as file}
|
||||
<!-- Download a single file -->
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||
>{getFileType(file.filename)}</DropdownMenu.Item
|
||||
>
|
||||
{/each}
|
||||
<!-- Download all files as zip -->
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
bookOps.downloadBooks([book.id], `${book.title}.zip`);
|
||||
}}
|
||||
>
|
||||
All (ZIP)
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Download</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{:else}
|
||||
<!-- There is only a single file, render simple buttons -->
|
||||
|
||||
<!-- Read button -->
|
||||
<Button
|
||||
class="w-full {buttonVariants({ variant: 'accent' })} drop-shadow-lg"
|
||||
onclick={() => openBookInReader(book.files[0])}
|
||||
>
|
||||
<BookOpenText class="mr-2 size-4" />
|
||||
Read
|
||||
</Button>
|
||||
|
||||
<!-- Download button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||
>
|
||||
<Download />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Download</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{#if book.series}
|
||||
<p class="mt-1 font-mono text-xs tracking-wider text-primary-foreground/70 uppercase">
|
||||
{book.series.title}{book.series_position ? ` · #${book.series_position}` : ''}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr_1fr_1fr_1fr] gap-2">
|
||||
<!-- Mark as finished button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class=" {buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} w-full drop-shadow-lg {book?.progress?.completed === true
|
||||
? 'text-green-500'
|
||||
: ''}"
|
||||
onclick={async () => {
|
||||
if (book?.progress?.completed === true)
|
||||
await bookOps.markBooksAsIncomplete([book.id]);
|
||||
else await bookOps.markBooksAsComplete([book.id]);
|
||||
}}
|
||||
>
|
||||
<BookOpenCheck />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
{#if !book?.progress?.completed}
|
||||
<p>Mark as finished</p>
|
||||
{:else}
|
||||
<p>Mark as not finished</p>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{#if book.authors && book.authors.length > 0}
|
||||
<p class="mt-2 line-clamp-1 text-primary-foreground/85">
|
||||
By
|
||||
{#each book.authors as author (author.id)}
|
||||
<a
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary!.id)
|
||||
})}?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Add to shelf button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="{buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} w-full drop-shadow-lg"
|
||||
>
|
||||
<Album />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1}
|
||||
onclick={async () => {
|
||||
if (book.lists.find((sh) => sh.id === shelf.id)) {
|
||||
await bookshelfState.removeBooksFromShelf(shelf.id, [book.id]);
|
||||
book.lists = book.lists.filter((sh) => sh.id !== shelf.id);
|
||||
} else {
|
||||
await bookshelfState.addBooksToShelf(shelf.id, [book.id]);
|
||||
book.lists.push(shelf);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shelf.title}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => createShelfDialogOpen = true}
|
||||
class="text-muted-foreground ">
|
||||
<PlusIcon class="size-4" />
|
||||
New Shelf
|
||||
<!-- Primary actions live on the band, not in a side rail -->
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2">
|
||||
{#if book.files.length > 1}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={bandPrimary}>
|
||||
<BookOpenText class="size-4" />
|
||||
{readLabel}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each book.files as file (file.id)}
|
||||
<DropdownMenu.Item onclick={() => openBookInReader(file)}>
|
||||
{getFileType(file.filename)}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Add to shelf</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Edit button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
onclick={() => {
|
||||
bookOps.bookToEdit = book;
|
||||
bookOps.editDialogOpen = true;
|
||||
}}
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={bandGhost}>
|
||||
<Download class="size-4" />
|
||||
Download
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each book.files as file (file.id)}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||
>
|
||||
{getFileType(file.filename)}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => bookOps.downloadBooks([book.id], `${book.title}.zip`)}
|
||||
>
|
||||
All (ZIP)
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else if book.files.length === 1}
|
||||
<button
|
||||
type="button"
|
||||
class={bandPrimary}
|
||||
onclick={() => openBookInReader(book.files[0])}
|
||||
>
|
||||
<Pencil />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Edit</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<BookOpenText class="size-4" />
|
||||
{readLabel}
|
||||
</button>
|
||||
|
||||
<!-- Delete button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
onclick={() => {
|
||||
bookOps.deleteDialogTitle = `Delete book?`;
|
||||
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||
await bookOps.deleteBooks([book.id], deleteFiles, false);
|
||||
libraryState.activeLibrary!.total!--
|
||||
bookshelfState.deletedBooks([book])
|
||||
await history.back();
|
||||
};
|
||||
bookOps.deleteDialogOpen = true;
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
class={bandGhost}
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Delete</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<Download class="size-4" />
|
||||
Download
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex flex-col gap-1">
|
||||
<!-- Title, subtitle, and authors -->
|
||||
<h1 class="text-2xl font-semibold">{book.title}</h1>
|
||||
|
||||
{#if book?.subtitle}
|
||||
<h2 class="text-lg">{book.subtitle}</h2>
|
||||
{/if}
|
||||
|
||||
{#if book.series}
|
||||
<h2 class="text-base text-muted-foreground">
|
||||
{book.series.title}
|
||||
{book.series_position ? `#${book.series_position}` : ''}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
{#if book.authors && book.authors.length > 0}
|
||||
<h2 class="line-clamp-1 w-full text-lg">
|
||||
By
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex flex-col gap-4 text-sm">
|
||||
<!-- Tags -->
|
||||
{#if book?.tags.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Tags size="20" />
|
||||
<span class="mr-2 text-sm">Tags: </span>
|
||||
{#each book.tags as tag (tag.id)}
|
||||
<a href="/tag/{tag.id}" class={badgeVariants({ variant: 'default' })}>{tag.name}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Identifiers -->
|
||||
{#if book.identifiers && Object.keys(book.identifiers).length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Link size="18" />
|
||||
<span class="mr-2">Identifiers: </span>
|
||||
{#each Object.entries(book.identifiers) as [name, value] (name)}
|
||||
<Badge>{name}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Publisher -->
|
||||
{#if book.publisher}
|
||||
<div class="flex items-center gap-2">
|
||||
<NotebookPen size="18" />
|
||||
<span class="mr-2">Publisher: </span>
|
||||
<span>{book.publisher.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Published date -->
|
||||
{#if book.published_date}
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarDays size="18" />
|
||||
<span class="mr-2">Date published: </span>
|
||||
<span class="text-sm">{book.published_date}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Page count -->
|
||||
{#if book.pages}
|
||||
<div class="flex items-center gap-2">
|
||||
<NotebookText size="18" />
|
||||
<span class="mr-2">Pages: </span>
|
||||
<span>{book.pages}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Description -->
|
||||
<!--
|
||||
Body. Source order is description → details → files, which is the mobile
|
||||
reading order asked for. On md+ the explicit row/column placement puts the
|
||||
details rail on the right and files back under the description.
|
||||
-->
|
||||
<!--
|
||||
Same horizontal frame as the band — matching px and max-width, so the
|
||||
description lines up with the cover's left edge.
|
||||
-->
|
||||
<div class="px-6 md:px-10">
|
||||
<!--
|
||||
grid-rows matters here. The rail spans both rows, and with two auto rows the
|
||||
browser splits any surplus rail height evenly between them — so a one-line
|
||||
description got stretched to half the rail's height, leaving a large gap
|
||||
above the files card. Sizing row 1 to its content and letting row 2 take the
|
||||
free space sends the slack to the bottom instead, where it is invisible.
|
||||
-->
|
||||
<div
|
||||
class="mx-auto grid w-full max-w-5xl gap-8 pt-28 pb-10 md:grid-cols-[minmax(0,1fr)_260px] md:grid-rows-[auto_1fr] md:items-start"
|
||||
>
|
||||
<!-- Description -->
|
||||
<section class="min-w-0 md:col-start-1 md:row-start-1">
|
||||
{#if book.description}
|
||||
<CollapsibleText text={book.description} maxLength={500} />
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No description for this book yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Details rail -->
|
||||
<aside class="flex flex-col gap-6 md:col-start-2 md:row-span-2 md:row-start-1">
|
||||
<div>
|
||||
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Details
|
||||
</h2>
|
||||
<!-- items-baseline: the 10px mono label and the 14px value sit on a
|
||||
shared text baseline, so the pair reads as one line -->
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-4 gap-y-2 rounded-lg border bg-card p-4 text-sm"
|
||||
>
|
||||
{#if book.publisher}
|
||||
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
Publisher
|
||||
</dt>
|
||||
<dd class="m-0">{book.publisher.name}</dd>
|
||||
{/if}
|
||||
{#if book.published_date}
|
||||
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
Published
|
||||
</dt>
|
||||
<dd class="m-0 font-mono tabular-nums">{book.published_date}</dd>
|
||||
{/if}
|
||||
{#if book.pages}
|
||||
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
Pages
|
||||
</dt>
|
||||
<dd class="m-0 font-mono tabular-nums">{book.pages}</dd>
|
||||
{/if}
|
||||
{#if book.language}
|
||||
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
Language
|
||||
</dt>
|
||||
<dd class="m-0">{book.language}</dd>
|
||||
{/if}
|
||||
{#if book.edition}
|
||||
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
Edition
|
||||
</dt>
|
||||
<dd class="m-0 font-mono tabular-nums">{book.edition}</dd>
|
||||
{/if}
|
||||
{#each sortIdentifiers(book.identifiers) as [name, value] (name)}
|
||||
{@const id = describeIdentifier(name, value)}
|
||||
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
{id.label}
|
||||
</dt>
|
||||
<dd class="m-0 font-mono break-all tabular-nums">
|
||||
{#if id.href}
|
||||
<!-- Always an absolute URL off-site: openlibrary, doi.org or amazon. -->
|
||||
<a
|
||||
href={id.href}
|
||||
target="_blank"
|
||||
rel="external noopener noreferrer"
|
||||
class="hover:text-primary hover:underline">{value}</a
|
||||
>
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{#if book.tags.length > 0}
|
||||
<div>
|
||||
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Tags
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each book.tags as tag (tag.id)}
|
||||
<!-- Was /tag/{id}, a route that has never existed — these badges 404'd.
|
||||
Filter the library view, as the tag links in the list views do. -->
|
||||
<a
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary!.id)
|
||||
})}?tags={tag.id}"
|
||||
class={badgeVariants({ variant: 'default' })}>{tag.name}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Book files -->
|
||||
<Accordion.Root
|
||||
type="single"
|
||||
class="mt-4 w-full rounded-lg bg-muted px-4 shadow-lg drop-shadow "
|
||||
>
|
||||
<!-- <Separator></Separator> -->
|
||||
<Accordion.Item value="item-1">
|
||||
{#if book.lists.length > 0}
|
||||
<div>
|
||||
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Shelves
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each book.lists as shelf (shelf.id)}
|
||||
<a
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary!.id)
|
||||
})}?shelves={shelf.id}"
|
||||
class={badgeVariants({ variant: 'outline' })}>{shelf.title}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Manage
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- Mark as finished -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} {book.progress
|
||||
?.completed
|
||||
? 'text-success'
|
||||
: ''}"
|
||||
onclick={async () => {
|
||||
if (book.progress?.completed) await bookOps.markBooksAsIncomplete([book.id]);
|
||||
else await bookOps.markBooksAsComplete([book.id]);
|
||||
}}
|
||||
>
|
||||
<BookOpenCheck />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>{book.progress?.completed ? 'Mark as not finished' : 'Mark as finished'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Add to shelf -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={buttonVariants({ variant: 'outline', size: 'icon' })}
|
||||
>
|
||||
<Album />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1}
|
||||
onclick={async () => {
|
||||
if (book.lists.find((sh) => sh.id === shelf.id)) {
|
||||
await bookshelfState.removeBooksFromShelf(shelf.id, [book.id]);
|
||||
book.lists = book.lists.filter((sh) => sh.id !== shelf.id);
|
||||
} else {
|
||||
await bookshelfState.addBooksToShelf(shelf.id, [book.id]);
|
||||
book.lists.push(shelf);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shelf.title}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => (createShelfDialogOpen = true)}
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<PlusIcon class="size-4" />
|
||||
New Shelf
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Add to shelf</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Edit -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
onclick={() => {
|
||||
bookOps.bookToEdit = book;
|
||||
bookOps.editDialogOpen = true;
|
||||
}}
|
||||
class={buttonVariants({ variant: 'outline', size: 'icon' })}
|
||||
>
|
||||
<Pencil />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Edit</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Delete -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class={buttonVariants({ variant: 'outline', size: 'icon' })}
|
||||
onclick={() => {
|
||||
bookOps.deleteDialogTitle = `Delete book?`;
|
||||
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||
await bookOps.deleteBooks([book.id], deleteFiles, false);
|
||||
libraryState.activeLibrary!.total!--;
|
||||
bookshelfState.deletedBooks([book]);
|
||||
await history.back();
|
||||
};
|
||||
bookOps.deleteDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Delete</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Library files -->
|
||||
<section class="min-w-0 md:col-start-1 md:row-start-2">
|
||||
<Accordion.Root type="single" class="w-full rounded-lg border bg-card px-4">
|
||||
<Accordion.Item value="files">
|
||||
<Accordion.Trigger>
|
||||
<div class="ml-2 flex gap-4">
|
||||
Library Files
|
||||
<div class="flex items-center gap-3">
|
||||
Library files
|
||||
<Badge>{book.files.length}</Badge>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
@@ -413,14 +478,20 @@
|
||||
{#each book.files as file (file.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="max-w-[300px] min-w-0 overflow-hidden">
|
||||
<div class="wrap-break-word whitespace-normal">{file.filename}</div>
|
||||
<div class="font-mono text-xs break-words whitespace-normal">
|
||||
{file.filename}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatFileSize(file.size)}</Table.Cell>
|
||||
<Table.Cell>{getFileType(file.filename)}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
><span class="font-mono text-xs">{getFileType(file.filename)}</span
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<!-- Read button -->
|
||||
{#if getFileType(file.filename) == 'EPUB' || getFileType(file.filename) == 'PDF'}
|
||||
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
@@ -428,6 +499,7 @@
|
||||
variant: 'default',
|
||||
size: 'icon'
|
||||
})} scale-90"
|
||||
onclick={() => openBookInReader(file)}
|
||||
>
|
||||
<BookOpenText />
|
||||
</Tooltip.Trigger>
|
||||
@@ -438,7 +510,6 @@
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
|
||||
<!-- Download button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
@@ -458,8 +529,6 @@
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Delete button -->
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
@@ -474,7 +543,7 @@
|
||||
>
|
||||
<Trash2 />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">Delete File</Tooltip.Content>
|
||||
<Tooltip.Content side="bottom">Delete file</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
@@ -486,8 +555,8 @@
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -517,11 +586,13 @@
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<ShelfCreateDialog
|
||||
bind:open={createShelfDialogOpen}
|
||||
<ShelfCreateDialog
|
||||
bind:open={createShelfDialogOpen}
|
||||
onSubmit={async (name: string) => {
|
||||
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [book.id])
|
||||
book.lists.push(bookshelf)
|
||||
createShelfDialogOpen = false
|
||||
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [
|
||||
book.id
|
||||
]);
|
||||
book.lists.push(bookshelf);
|
||||
createShelfDialogOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import ChapterSidebar from '$lib/components/reader/chapter-sidebar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user