Compare commits

...
39 Commits
Author SHA1 Message Date
patrick 968166c1fd Merge branch 'ui-rewrite' 2026-08-13 13:52:47 -04:00
patrick 8589adbd1b docs: record the outstanding backend issues in TODO.md 2026-08-13 00:42:54 -04:00
patrick 5305d3bb5e docs: correct the CSP entry in TODO.md
Scripted EPUBs are a regression from the foliate migration, not a pre-existing
gap: epub.js sandboxed without allow-scripts by default, foliate sets it
unconditionally. Records grimmory's fix — CSP on per-entry responses rather
than app-wide — which also sidesteps the mode-watcher blocker, plus the
whole-file buffering it would remove.
2026-08-12 14:21:10 -04:00
patrick 7e04826fa5 feat: draw covers for books without one
Full-bleed serif title on a woven ground, drawn in the browser rather than
stored, sized with container queries so one component works from 36px to full
page. Colour is hashed into a list of bookcloth tones instead of the hue wheel.

The grid and search were falling back to default_cover.jpg because a missing
cover made the src "/api/undefined"; they now check for one first.
2026-08-12 13:44:14 -04:00
patrick 96789620bb feat: run book uploads in a background tray
One request per book instead of one for everything, queued in state above the
dialog so closing it no longer cancels the import. A docked tray reports each
book and offers a retry; it clears itself after a clean run, stays put if
anything failed, and holds while hovered.

Also fixes the dialog overflowing on long filenames — Dialog.Content is a grid
and its body needed min-w-0 to shrink below the content's intrinsic width.
2026-08-12 13:42:40 -04:00
patrick d4bdb5ed42 feat: accept files and folders when uploading books
The picker could only choose folders: webkitdirectory switches the OS dialog
into folder mode rather than filtering, so one input cannot offer both. Adds
a drop zone with two browse buttons behind two inputs.

Dropping a folder never worked either — dataTransfer.files does not descend
into directories, so it arrived as one typeless entry and was rejected. Walks
dataTransfer.items instead.

Also collects skipped files into one expandable line rather than a toast each,
which a folder of covers and notes made unusable, and shows the file count and
total size before uploading.
2026-08-12 12:04:14 -04:00
patrick 5f2d68694d feat: rebuild the edit dialog around a cover and files rail
Drops the three tabs for a wide dialog: cover and files in a fixed rail,
all fourteen metadata fields grouped beside them, Save in the footer.

Adds file management, which the Files tab never had — it could only upload,
never list or remove. Files now show size and format with download and
delete, the latter asking whether to remove it from disk too.
2026-08-12 11:40:49 -04:00
patrick d6207b5743 refactor: resolve() internal links instead of plain hrefs
Type-checks every link against the real route tree. Caught a dead one: the
book page linked tags to /tag/{id}, a route that has never existed.
2026-08-12 11:08:09 -04:00
patrick 51c31e6bf6 docs: document the foliate-js reader
Adds a reader section covering the vendored tree, the $foliate alias and the
traps around it, and refreshes the stale stack line, oklch claim and rough
edges. Records why CSP is not enabled in TODO.md.
2026-08-12 01:37:19 -04:00
patrick 961a63480e chore: drop epubjs
Nothing imports it since the foliate rewrite. Also note the one-shot purge of
its locations cache in TODO.md, so the temporary code gets removed later.
2026-08-11 23:15:39 -04:00
patrick 92ffa4f7c2 fix: forward app shortcuts pressed inside the book
Key events do not cross document boundaries, so shortcuts bound on the window
never fired while the book had focus and the browser default won instead —
ctrl+B opened bookmarks rather than the chapter sidebar. Replay them on the
window, cancelling the original only if a handler claimed it so ctrl+C still
copies.
2026-08-11 23:12:00 -04:00
patrick 9155129ccd fix: make the reader's typography, width and arrow keys work
Typography was in the stylesheet foliate prepends, which the book overrides;
moved to the appended one. The window keydown handler was never bound.

Also: wider reading area, page on a card, arrows moved onto it, spread gutter,
and the sidebar stays open on chapter clicks.
2026-08-11 22:45:14 -04:00
patrick 6318115380 fix: make the vendored slider track visible
shadcn-svelte generates 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. The track
got no height, leaving only the thumb on screen. Use the `data-[orientation=…]`
form the rest of ui/ already uses.
2026-08-11 22:43:22 -04:00
patrick fe12f4be76 feat: add the reader settings panel
Typeface, size, weight, line height, letter spacing, justify and hyphenate,
plus flow, columns, line width, gap and margins — persisted in localStorage
and applied live.

Fixed-layout books hide the typography and column controls: they render
through foliate-fxl, which has no setStyles and ignores the paginator's
attributes, so those sliders would do nothing.
2026-08-11 22:27:04 -04:00
patrick 190e7af76d feat: replace the epub reader with foliate-js
Drops epub.js's 1600-location pre-pass and its localStorage cache: foliate
hands back a CFI and an overall fraction on every relocate, so first open no
longer freezes.

Also fixes resuming (pre-resolves the stored CFI, falling back to the stored
percentage rather than silently resetting to page one), progress saving
(missing Content-Type, and no flush on the way out), and arrow keys inside
the book iframe.
2026-08-11 22:21:15 -04:00
patrick 783f4d226d feat: add reader settings state and stylesheet generator
Settings are validated on read, not only on write: several go straight onto
foliate's renderer as custom-element attributes, where a bad value wedges the
layout silently rather than throwing. Unknown keys are stripped and missing
ones filled from the defaults, so settings added later do not invalidate what
a reader already has stored.

The stylesheet is built as foliate's [before, after] pair. The paginator
prepends the first to the section's <head> and appends the second, so the
book's own CSS overrides the first and loses to the second. Typography goes
in the first — a book that styles its own headings still gets to, and font
size lands on <html> without !important so rem/em headings scale rather than
being flattened. Colour goes in the second, because most EPUBs set a body
background and would otherwise leave the page white against a dark UI.

Font choices are reused from the app theme's FONT_STACKS rather than
duplicated, so the reader cannot drift from the rest of the UI.

Rename the ambient declaration to foliate-js.d.ts: as foliate.d.ts beside
foliate.ts, TypeScript takes it for that file's emitted declaration, drops it
from the program, and every $foliate import fails with TS2307.
2026-08-11 22:08:58 -04:00
patrick c946b6d71c chore: wire vendored foliate-js into the build
$foliate is a Vite-only alias, deliberately absent from kit.alias and
tsconfig paths: TypeScript cannot resolve it, so the ambient declaration in
src/lib/reader/foliate.d.ts is the only candidate and svelte-check never
walks the ~11k lines of untyped vendored JS. The generated tsconfig includes
src/**/*.js and checkJs is on, so src/lib/vendor is excluded as well —
`exclude` replaces rather than merges, hence the repeated service-worker
entries.

optimizeDeps.include for construct-style-sheets-polyfill: it sits behind a
dynamic import in fixed-layout.js, so Vite would otherwise discover it
mid-session and force a page reload on the first fixed-layout EPUB.

Verified with a temporary import that the graph resolves: view.js and
paginator.js are emitted as chunks, and the bundled pdf.js is our stub
rather than upstream's bare `import '@pdfjs/pdf.min.mjs'`.
2026-08-11 22:04:50 -04:00
patrick dd65e34869 chore: vendor foliate-js
foliate-js has no npm release and no build step; upstream recommends a git
submodule. Copy it instead: this repo has no submodules and already vendors
pdf.js the same way under static/pdfjs, and a submodule would pull in 231
files / 13 MB of which 191 files / 12 MB is a bundled pdf.js build that
Chitai does not use.

Only the import closure reachable from view.js is vendored — 15 files, 584K.
pdf.js is replaced by a stub that throws: view.js reaches it through a
static-string dynamic import inside makeBook, which Rollup resolves at build
time even though Chitai serves PDFs from static/pdfjs/web/viewer.html, and
upstream's version opens with a bare `import '@pdfjs/pdf.min.mjs'` that does
not resolve here.

scripts/vendor-foliate.sh pins the commit and makes the next update a one-line
change. fixed-layout.js needs construct-style-sheets-polyfill, so add it.
2026-08-11 21:59:33 -04:00
patrick a1281f129c chore: unbreak prettier by bumping prettier-plugin-tailwindcss
prettier-plugin-tailwindcss 0.6.14 is incompatible with prettier 3.8.1 and
threw "getVisitorKeys is not a function" on every .svelte file, so `pnpm
lint` could not run at all. 0.8.1 fixes it.

No reformatting here: the files prettier now reports are mostly vendored
shadcn components, and rewriting those is unrelated to any current work.
2026-08-11 21:54:17 -04:00
patrick ee46f5568c fix: resume epub progress from the fields the API returns
The load function read progress.epub_loc and progress.progress, neither of
which BookProgressRead carries, so resuming never worked and every open
started at page one. Read epub_cfi and percentage instead.

Fail loudly when the book cannot be fetched. The previous version caught
everything and returned {status, error} that nothing consumed, so a missing
book produced a reader sitting on its spinner forever. Check response.ok
before parsing too: fetch resolves on a 404, and arrayBuffer() happily
returns the error page, which only surfaced later as an opaque epub parse
error.

Give the reader a working sidebar, a theme that survives the book's own
CSS, and an error state with a retry. The theme is registered rather than
applied as bare overrides because most EPUBs ship their own body colours
and win otherwise, which is why the page stayed white against a dark UI.
2026-08-11 21:47:12 -04:00
patrick ac5a5c75aa fix: surface reader load failures instead of spinning forever
The PDF reader pointed an iframe at the vendored pdf.js viewer and had no
way to report a failure: the viewer renders its own errors inside the
frame, so a missing or unreadable file showed an empty grey panel. Probe
the file with a request first and render an error card with a retry when
that fails, matching the chrome the EPUB reader uses.

Add the missing +page.ts for the PDF route so the tab gets the book title
rather than a bare "Reader".

Drop chapter-sidebar.svelte and its import. It rendered a list of chapters
whose buttons had no click handler, and read/+layout@.svelte imported it
without ever rendering it. The reader's own sidebar replaces it.
2026-08-11 21:47:03 -04:00
patrick 49f94f9ee1 fix: mount ModeWatcher and Toaster at the root layout
The reader and login pages use +layout@ breakouts to escape the (root)
shell, so anything mounted in (root)/+layout.svelte never reaches them.
ModeWatcher was mounted there and duplicated in login/+layout@.svelte,
which left the reader's theme toggle inert: it flipped the store but
nothing wrote .dark onto <html>.

Mount both once at the top-level +layout.svelte instead, which every
route inherits regardless of breakouts.
2026-08-11 21:46:50 -04:00
patrick 2aba90f910 chore: clear epubjs type import and empty server load 2026-08-11 20:23:31 -04:00
patrick d2892353cb fix: reset the edit dialog when switching books 2026-08-11 20:23:31 -04:00
patrick d8f8a0ab95 docs: add TODO.md 2026-08-11 20:08:53 -04:00
patrick 4ee127b6cb fix: remove duplicate separator in the shelves dropdown 2026-08-11 20:08:53 -04:00
patrick 6366582498 fix: silence state_referenced_locally in vendored ui components 2026-08-11 20:08:53 -04:00
patrick ef8e5e7fba fix: stop icons remounting and shelves collapsing into nothing 2026-08-11 20:08:53 -04:00
patrick c09b8365fa feat: put the library view mode in the url via shallow routing 2026-08-11 20:08:53 -04:00
patrick 0139f6f5eb feat: extract built-in view presets and add filter chips 2026-08-11 20:08:53 -04:00
patrick 4c3fd66a56 feat: rebuild the book page with the jacket layout 2026-08-11 20:08:53 -04:00
patrick f2b8f337f7 feat: rebuild the table view with sortable, toggleable columns 2026-08-11 20:08:53 -04:00
patrick 75360ff603 feat: add book cover component and detail-card list view 2026-08-11 20:08:53 -04:00
patrick 87fff20d72 feat: widen search and label the upload button 2026-08-11 20:08:53 -04:00
patrick adddcfeeed feat: add account menu to the sidebar footer 2026-08-11 20:08:53 -04:00
patrick cb070336c6 feat: replace the sidebar logo with a book mark 2026-08-11 20:08:53 -04:00
patrick f68a233dca refactor: replace hardcoded colours with semantic tokens 2026-08-11 20:08:53 -04:00
patrick f36aa14463 feat: add theme editor in settings 2026-08-11 20:08:53 -04:00
patrick d71b53b3c5 feat: add themeable design tokens with cookie-backed SSR 2026-08-11 20:08:53 -04:00
117 changed files with 18041 additions and 2105 deletions
+4 -1
View File
@@ -1,2 +1,5 @@
# Mark pdfjs as vendored code # 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 -7
View File
@@ -9,13 +9,15 @@ a KOSync-compatible endpoint.
## Layout ## Layout
| Path | What | | Path | What |
| --- | --- | | -------------------------- | ---------------------------------------------------------------------------------------- |
| `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. | | `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. |
| `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. | | `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. |
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. | | `frontend/src/lib/vendor/` | Vendored `foliate-js` (the EPUB engine), copied by `frontend/scripts/vendor-foliate.sh`. |
| `docs/screenshots/` | Images used by `README.md`. | | `frontend/static/pdfjs/` | Vendored pdf.js viewer, used by the PDF reader in an iframe. |
| `shell.nix` | Root dev shell; composes the two sub-shells. | | `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 ## Development environment
+335
View File
@@ -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 510 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.
+3
View File
@@ -11,3 +11,6 @@ coverage
# Miscellaneous # Miscellaneous
/static/ /static/
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
/src/lib/vendor/
+74 -16
View File
@@ -4,13 +4,16 @@ SvelteKit web app for the eBook library. See the repo-root `AGENTS.md` for the o
dev-environment setup. dev-environment setup.
**Stack:** SvelteKit 2 with `adapter-node` · Svelte 5 (runes) · Tailwind v4 · Zod v4 · **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: 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). `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 Tailwind v4 has **no config file** — the theme, colour tokens (hex, not oklch) and
live in `src/app.css`. `@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 ## Talking to the backend
@@ -65,8 +68,12 @@ Svelte context with a module-level `Symbol` key and a `setXState` / `getXState`
```ts ```ts
const LIBRARY_KEY = Symbol('LIBRARY'); const LIBRARY_KEY = Symbol('LIBRARY');
export function setLibraryState(libraries: Library[]) { return setContext(LIBRARY_KEY, new LibraryState(libraries)); } export function setLibraryState(libraries: Library[]) {
export function getLibraryState() { return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY); } 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 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 `@ieedan/shadcn-svelte-extras` (`jsrepo.json`). Treat as generated: add components with the CLIs
rather than hand-writing them, and prefer wrapping over editing. rather than hand-writing them, and prefer wrapping over editing.
- App components live in `forms/`, `layout/`, `view/` (browser, grid/list/table, filters, sort) and - 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 - `cn()` from `$lib/utils` merges Tailwind classes; the `WithElementRef` / `WithoutChild` helpers
there are the shadcn prop-typing conventions. 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 ## Routing
Route groups carry the layout structure: Route groups carry the layout structure:
@@ -95,21 +148,26 @@ Route groups carry the layout structure:
## Conventions ## Conventions
Prettier (`.prettierrc`): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and 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 ## Known rough edges
Observed in the current tree — don't mistake these for intentional patterns to copy: 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 - `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 no `BookProgressRead`, so `book.progress` types as `{}`. It is the source of most of the errors
errors `pnpm check` reports on a clean tree; regenerating it should clear them. Get a baseline `pnpm check` reports; regenerating it should clear them. **Baseline as of 2026-08-11: 37 errors,
before assuming an error is yours. 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 - `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4. import of that type is commented out at line 4. It also buffers whole responses with
- `src/app.d.ts``App.Locals["user"]` is typed from `lucide-svelte`'s `User` *icon* component `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`. rather than the `User` interface in `$lib/server/auth`.
- Uncommitted work in progress (as of 2026-08-10): library icons, spanning - No CSP, which foliate's README asks for because EPUBs can carry scripts. See `TODO.md` for why it
`components/ui/icon-picker/`, the newly vendored `components/ui/popover/`, is not enabled yet.
`forms/library-create-form.svelte`, `layout/library-switcher.svelte` and `schema/library.ts`.
Prefer not to refactor those files mid-flight.
+8
View File
@@ -12,6 +12,8 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
// Vendored third-party source. Tracked, so .gitignore does not cover it.
{ ignores: ['src/lib/vendor/**'] },
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs.recommended, ...svelte.configs.recommended,
@@ -27,6 +29,12 @@ export default defineConfig(
'no-undef': 'off' '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'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: { languageOptions: {
+3 -3
View File
@@ -33,8 +33,8 @@
"jsrepo": "^2.5.2", "jsrepo": "^2.5.2",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1", "prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.53.7", "svelte": "^5.53.7",
"svelte-check": "^4.4.5", "svelte-check": "^4.4.5",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
@@ -47,7 +47,7 @@
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"epubjs": "^0.3.93", "construct-style-sheets-polyfill": "^3.1.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.8", "svelte-sonner": "^1.0.8",
"zod": "^4.3.6" "zod": "^4.3.6"
+102 -291
View File
File diff suppressed because it is too large Load Diff
+129
View File
@@ -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
View File
@@ -8,74 +8,100 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695); /* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
--card: oklch(1 0 0); --app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--card-foreground: oklch(0.129 0.042 264.695); --app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
--popover: oklch(1 0 0); --app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755); /* Reading Room — light */
--primary-foreground: oklch(0.984 0.003 247.858); --background: #e9ebee;
--secondary: oklch(0.968 0.007 247.896); --foreground: #1b1f24;
--secondary-foreground: oklch(0.208 0.042 265.755); --card: #fbfbfc;
--muted: oklch(0.968 0.007 247.896); --card-foreground: #1b1f24;
--muted-foreground: oklch(0.554 0.046 257.417); --popover: #fbfbfc;
--accent: oklch(0.968 0.007 247.896); --popover-foreground: #1b1f24;
--accent-foreground: oklch(0.208 0.042 265.755); --primary: #1f5f5b;
--destructive: oklch(0.577 0.245 27.325); --primary-foreground: #f2f7f6;
--border: oklch(0.929 0.013 255.508); --secondary: #dfe3e8;
--input: oklch(0.929 0.013 255.508); --secondary-foreground: #1b1f24;
--ring: oklch(0.704 0.04 256.788); --muted: #e2e5e9;
--chart-1: oklch(0.646 0.222 41.116); --muted-foreground: #7d858f;
--chart-2: oklch(0.6 0.118 184.704); --accent: #d7e5e3;
--chart-3: oklch(0.398 0.07 227.392); --accent-foreground: #164743;
--chart-4: oklch(0.828 0.189 84.429); --destructive: #a6402f;
--chart-5: oklch(0.769 0.188 70.08); --border: #d2d6dc;
--sidebar: oklch(0.984 0.003 247.858); --input: #d2d6dc;
--sidebar-foreground: oklch(0.129 0.042 264.695); --ring: #1f5f5b;
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858); /* Semantic — deliberately not the accent, so state never reads as branding. */
--sidebar-accent: oklch(0.968 0.007 247.896); --success: #2f7d4f;
--sidebar-accent-foreground: oklch(0.208 0.042 265.755); --success-foreground: #f2f7f6;
--sidebar-border: oklch(0.929 0.013 255.508); --flag: #b08a1e;
--sidebar-ring: oklch(0.704 0.04 256.788); --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 { .dark {
--background: oklch(0.129 0.042 264.695); /* Reading Room — dark */
--foreground: oklch(0.984 0.003 247.858); --background: #15191b;
--card: oklch(0.208 0.042 265.755); --foreground: #e6eae9;
--card-foreground: oklch(0.984 0.003 247.858); --card: #1d2226;
--popover: oklch(0.208 0.042 265.755); --card-foreground: #e6eae9;
--popover-foreground: oklch(0.984 0.003 247.858); --popover: #1d2226;
--primary: oklch(0.929 0.013 255.508); --popover-foreground: #e6eae9;
--primary-foreground: oklch(0.208 0.042 265.755); --primary: #6fbab0;
--secondary: oklch(0.279 0.041 260.031); --primary-foreground: #0e1a19;
--secondary-foreground: oklch(0.984 0.003 247.858); --secondary: #232a2d;
--muted: oklch(0.279 0.041 260.031); --secondary-foreground: #e6eae9;
--muted-foreground: oklch(0.704 0.04 256.788); --muted: #232a2d;
--accent: oklch(0.279 0.041 260.031); --muted-foreground: #78868a;
--accent-foreground: oklch(0.984 0.003 247.858); --accent: #1b3330;
--destructive: oklch(0.704 0.191 22.216); --accent-foreground: #9fd8d0;
--border: oklch(1 0 0 / 10%); --destructive: #e0796b;
--input: oklch(1 0 0 / 15%); --border: #2a3034;
--ring: oklch(0.551 0.027 264.364); --input: #2a3034;
--chart-1: oklch(0.488 0.243 264.376); --ring: #6fbab0;
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --success: #4fa97a;
--chart-4: oklch(0.627 0.265 303.9); --success-foreground: #0e1a19;
--chart-5: oklch(0.645 0.246 16.439); --flag: #d4a63a;
--sidebar: oklch(0.208 0.042 265.755); --star: #e5b84b;
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376); --chart-1: #6fbab0;
--sidebar-primary-foreground: oklch(0.984 0.003 247.858); --chart-2: #4fa97a;
--sidebar-accent: oklch(0.279 0.041 260.031); --chart-3: #d4a63a;
--sidebar-accent-foreground: oklch(0.984 0.003 247.858); --chart-4: #7f9dc0;
--sidebar-border: oklch(1 0 0 / 10%); --chart-5: #e0796b;
--sidebar-ring: oklch(0.551 0.027 264.364);
--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 { @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-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -95,6 +121,10 @@
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --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-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
+3 -1
View File
@@ -2,7 +2,8 @@
// for information about these interfaces // for information about these interfaces
import type { ApiClient } from '$lib/server/api'; 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 { declare global {
namespace App { namespace App {
@@ -11,6 +12,7 @@ declare global {
authToken: string | null; authToken: string | null;
api: ApiClient; api: ApiClient;
user: User; user: User;
theme: ThemeConfig;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<!--theme-->
</head> </head>
<body data-sveltekit-preload-data="hover" class="overflow-hidden"> <body data-sveltekit-preload-data="hover" class="overflow-hidden">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
+26 -1
View File
@@ -1,5 +1,6 @@
import { ApiClient } from '$lib/server/api'; import { ApiClient } from '$lib/server/api';
import { validateToken } from '$lib/server/auth'; import { validateToken } from '$lib/server/auth';
import { THEME_COOKIE, parseThemeCookie, themeToCss } from '$lib/theme/presets';
import { redirect, type Handle } from '@sveltejs/kit'; import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks'; import { sequence } from '@sveltejs/kit/hooks';
@@ -34,4 +35,28 @@ const protectedRoutesHandle: Handle = async ({ event, resolve }) => {
return resolve(event); 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"> <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 Dialog from '$lib/components/ui/dialog/index.js';
import * as Field from '$lib/components/ui/field/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 * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { import { Button } from '$lib/components/ui/button';
displaySize,
FileDropZone,
type FileDropZoneProps
} from '$lib/components/ui/file-drop-zone';
import { X } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { Switch } from '$lib/components/ui/switch/index'; 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 { getLibraryState } from '$lib/state/library.svelte';
import { uploadBooks } from '$lib/api'; import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
import type { Book, PaginatedResponse } from '$lib/schema';
import { goto } from '$app/navigation';
let { open = $bindable() }: { open?: boolean } = $props(); let { open = $bindable() }: { open?: boolean } = $props();
let libraryState = getLibraryState(); const libraryState = getLibraryState();
const queue = getUploadQueueState();
$effect(() => {
uploadBooks.fields.library_id.set(libraryState.activeLibrary!.id);
});
let files = $derived(uploadBooks.fields.files.value() ?? []);
let libraryId = $state<number>(untrack(() => libraryState.activeLibrary!.id));
let files = $state<File[]>([]);
let autoUploadOnDrop = $state(true); let autoUploadOnDrop = $state(true);
let navigateOnUpload = $state(true); let navigateOnUpload = $state(true);
let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => { const totalSize = $derived(files.reduce((sum, file) => sum + file.size, 0));
// 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 }) * 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) { // Same relative path twice is the same file — dropping a folder a second
await tick(); // time should not queue everything again.
formEl?.requestSubmit(); 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 }) => { const onFileRejected = ({ reason, file }: { reason: FileRejectedReason; file: File }) => {
toast.error(`${file.name} failed to upload!`, { description: reason }); 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; open = false;
let libraryId = books.items[0].library_id;
libraryState.setActive(libraryId); queue.enqueue(target, queued, ({ created, firstBook }) => {
if (books.items.length === 1) { if (created === 0) return;
goto(`/book/${books.items[0].id}`);
} else { const library = libraryState.libraries.find((lib) => lib.id === target);
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`); 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> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content> <!--
{#if uploadBooks.pending} Wider than the default lg: a folder's worth of rows needs the room.
<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>
<form overflow-hidden and the min-w-0 on the body below are what keep a long
{...uploadBooks.enhance(async ({ submit, form }) => { filename inside the dialog. Dialog.Content is a grid, and grid and flex
try { items default to min-width:auto — they refuse to shrink below their
await submit(); 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 <div class="flex w-full min-w-0 flex-col gap-3">
const issues = uploadBooks.fields.allIssues(); <div class="flex flex-col gap-1.5">
if (issues && issues.length > 0) { <Field.Label for="library_id">Library</Field.Label>
return; <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>
// 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>
{/each} {/each}
</NativeSelect.Root> </NativeSelect.Root>
</div>
<FileDropZone <BookDropZone
{onUpload} {onUpload}
{onFileRejected} {onFileRejected}
directory={true} accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook" />
sublabel="Only PDF, EPUB, and MOBI files supported"
/> {#if files.length > 0}
<input class="hidden" {...uploadBooks.fields.files.as('file multiple')} /> <div class="flex items-baseline justify-between border-b pb-1 text-sm">
<div class="flex max-h-[300px] flex-col gap-2 overflow-y-auto"> <span><strong class="tabular-nums">{files.length}</strong> ready to upload</span>
{#each files as file, idx} <span class="font-mono text-xs text-muted-foreground tabular-nums">
<div class="flex place-items-center justify-between gap-2"> {displaySize(totalSize)}
<div class="flex flex-col"> </span>
<span>{file.name}</span> </div>
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span> {/if}
</div>
<Button <div class="flex max-h-[300px] min-w-0 flex-col gap-2 overflow-y-auto">
variant="outline" {#each files as file, idx (file.name)}
size="icon" {@const location = splitPath(file.name)}
onclick={() => { <div class="flex min-w-0 items-center justify-between gap-2">
uploadBooks.fields.files.set([ <!-- flex-1 as well as min-w-0: without a constrained width there is
...Array.from(files).slice(0, idx), nothing for truncate to act against and the row pushes the dialog wide -->
...Array.from(files).slice(idx + 1) <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}
<X /> <span class="truncate font-mono" title={location.dir}>{location.dir}</span>
</Button> {/if}
<span class="shrink-0 tabular-nums">{displaySize(file.size)}</span>
</span>
</div> </div>
{/each} <Button
</div> 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"> {#if rejected.length > 0}
<div class="flex items-center gap-2"> <div class="rounded-md border border-star/50 bg-star/10 p-2 text-sm">
<Switch bind:checked={autoUploadOnDrop} /> <div class="flex items-center justify-between gap-2">
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label> <span>
<Button type="submit" class="ml-auto w-fit">Upload</Button> {rejected.length}
</div> {rejected.length === 1 ? 'file was' : 'files were'} skipped
<div class="flex items-center gap-2"> </span>
<Switch bind:checked={navigateOnUpload} /> <Button variant="ghost" size="sm" onclick={() => (showRejected = !showRejected)}>
<Field.Label for="navigate-to-book">Navigate to book on upload</Field.Label> {showRejected ? 'Hide' : 'Show'}
</Button>
</div> </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> </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.Content>
</Dialog.Root> </Dialog.Root>
@@ -1,39 +1,68 @@
<script lang="ts"> <script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js'; 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 { type Book } from '$lib/schema';
import EditCover from './edit-cover.svelte'; import EditCover from './edit-cover.svelte';
import EditFiles from './edit-files.svelte'; import EditFiles from './edit-files.svelte';
import EditMetadata from './edit-metadata.svelte'; import EditMetadata from './edit-metadata.svelte';
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props(); 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> </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> <Dialog.Root bind:open>
{#if book} {#if book}
<Dialog.Content class="sm:max-w-xl"> {#key book.id}
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]"> <Dialog.Content
<Tabs.List class="grid w-full grid-cols-3"> class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger> >
<Tabs.Trigger value="cover">Cover</Tabs.Trigger> <Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
<Tabs.Trigger value="files">Files</Tabs.Trigger> <Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
</Tabs.List> >
<Dialog.Description class="truncate text-xs">
{book.authors.map((author) => author.name).join(', ') || 'Unknown author'}
</Dialog.Description>
</Dialog.Header>
<!-- Metadata form --> <!-- Rail and form scroll independently so the footer never moves -->
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1"> <div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_1fr]">
<EditMetadata {book} {open} /> <aside
</Tabs.Content> 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 --> <div class="min-h-0 overflow-y-auto p-5">
<Tabs.Content value="cover"> <EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
<EditCover {book} {open} /> </div>
</Tabs.Content> </div>
<!-- Add files form --> <Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
<Tabs.Content value="files"> <!-- Cover and file changes hit the server as they happen, while the
<EditFiles {book} /> fields wait for Save. Saying so is the cheapest way to stop
</Tabs.Content> Cancel reading as "undo everything". -->
</Tabs.Root> <p class="text-xs text-muted-foreground">Cover and file changes apply immediately</p>
</Dialog.Content> <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} {/if}
</Dialog.Root> </Dialog.Root>
@@ -1,33 +1,23 @@
<script lang="ts"> <script lang="ts">
import { import { tick, untrack } from 'svelte';
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 { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { updateBookCover } from '$lib/api'; import { updateBookCover } from '$lib/api';
import type { Book } from '$lib/schema'; import type { Book } from '$lib/schema';
import { tick, untrack } from 'svelte'; import BookImage from '$lib/components/view/book-image.svelte';
import { X } from '@lucide/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 formEl = $state<HTMLFormElement>();
let coverImagePreview = $state(`/api/${book.cover_image}`); // Seeded once per mount — see the key in edit-book.svelte
let autoUploadOnDrop = $state(true); let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => { const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
updateBookCover.fields.file.set(uploadedFiles[0]); updateBookCover.fields.file.set(uploadedFiles[0]);
updateCoverPreview(); updateCoverPreview();
if (autoUploadOnDrop && updateBookCover.fields.file.value()) { await tick();
await tick(); formEl?.requestSubmit();
formEl?.requestSubmit();
}
}; };
function updateCoverPreview() { function updateCoverPreview() {
@@ -35,14 +25,14 @@
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith('image/')) {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
coverImagePreview = reader.result; coverImagePreview = reader.result as string;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
} }
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, 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(() => { $effect(() => {
@@ -53,65 +43,40 @@
}); });
</script> </script>
<form <section class="flex min-w-0 flex-col gap-2">
bind:this={formEl} <h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Cover</h3>
{...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')} />
<div class="flex flex-col gap-2"> <BookImage src={coverImagePreview} class="w-full rounded-md border" />
<BookImage src={coverImagePreview} class="w-64 rounded" />
</div> <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 <FileDropZone
{onUpload} {onUpload}
{onFileRejected} {onFileRejected}
accept=".jpeg,.jpg,.png,.webp,image/*" accept=".jpeg,.jpg,.png,.webp,image/*"
label="Only JPEG, PNG, and WEBP images supported" label="Replace cover"
sublabel="JPEG, PNG or WEBP"
maxFiles={1} maxFiles={1}
fileCount={updateBookCover.fields.file.value() ? 1 : 0} fileCount={updateBookCover.fields.file.value() ? 1 : 0}
/> />
<input class="hidden" {...updateBookCover.fields.file.as('file')} /> </form>
<div class="flex flex-col gap-2"> </section>
{#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>
@@ -1,98 +1,196 @@
<script lang="ts"> <script lang="ts">
import { uploadBookFiles } from '$lib/api'; import { tick, untrack } from 'svelte';
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 { toast } from 'svelte-sonner'; 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 { 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>(); let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => { const onUpload: FileDropZoneProps['onUpload'] = async (uploaded) => {
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]); uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
if (autoUploadOnDrop && files.length > 0) { await tick();
await tick(); formEl?.requestSubmit();
formEl?.requestSubmit();
}
}; };
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => { 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> </script>
<form <section class="flex min-w-0 flex-col gap-2">
{...uploadBookFiles.enhance(async ({ submit, form }) => { <h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
try {
await submit();
// Check if there are any validation issues {#if files.length === 0}
const issues = uploadBookFiles.fields.allIssues(); <p class="text-xs text-muted-foreground">
if (issues && issues.length > 0) { No files yet. Add one below so this book can be read or downloaded.
return; </p>
} {/if}
// Reset the files field <ul class="flex flex-col gap-1.5">
uploadBookFiles.fields.files.set([]); {#each files as file (file.id)}
toast.success('Files successfully added!'); <li class="flex items-center gap-2 rounded-md border bg-background p-2">
} catch (error) { <span
console.error('Failed to upload files: ', error); class="rounded-sm bg-accent px-1.5 py-0.5 font-mono text-[9px] font-semibold text-accent-foreground"
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)
]);
}}
> >
<X /> {getFileType(file.filename)}
</Button> </span>
</div>
{/each}
</div>
<div class="flex flex-row items-center space-x-2"> <span class="min-w-0 flex-1">
<Switch bind:checked={autoUploadOnDrop} /> <span class="block truncate text-xs" title={file.filename}>{file.filename}</span>
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label> <span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
<Button type="submit" class="ml-auto w-fit">Upload</Button> {formatFileSize(file.size)}
</div> </span>
</form> </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"> <script lang="ts">
import * as Card from '$lib/components/ui/card/index.js'; import { untrack } from 'svelte';
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 { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Minus, Plus } from '@lucide/svelte';
import { updateBookMetadata } from '$lib/api'; import { updateBookMetadata } from '$lib/api';
import type { Book } from '$lib/schema'; 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 {
let tags = $state(book.tags.map((tag) => tag.name) || []); book,
let identifierKeys = $state(Object.keys(book.identifiers)); open = $bindable(),
let identifierValues = $state(Object.values(book.identifiers)); /** 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() { function handleAddIdentifier() {
// Add empty strings to both arrays
identifierKeys = [...identifierKeys, '']; identifierKeys = [...identifierKeys, ''];
identifierValues = [...identifierValues, '']; identifierValues = [...identifierValues, ''];
} }
@@ -86,197 +92,187 @@
}); });
</script> </script>
<Card.Root class="w-full "> {#snippet groupHeading(label: string)}
<form <h3
{...updateBookMetadata.enhance(async ({ submit, form }) => { class="col-span-full mt-2 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
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.');
}
})}
> >
<Card.Content> {label}
<Field.Set> </h3>
<Field.Group class="flex flex-col"> {/snippet}
<!-- 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>
<!-- Title field --> <form
<Field.Field> id={formId}
<Field.Label for="title">Title</Field.Label> {...updateBookMetadata.enhance(async ({ submit }) => {
<Input {...updateBookMetadata.fields.title.as('text')} /> try {
{#each updateBookMetadata.fields.title.issues() ?? [] as issue} await submit();
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Subtitle field --> // Check if there are any validation issues
<Field.Field> const issues = updateBookMetadata.fields.allIssues();
<Field.Label for="subtitle">Subtitle</Field.Label> if (issues && issues.length > 0) {
<Input {...updateBookMetadata.fields.subtitle.as('text')} /> return;
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue} }
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<div class="grid grid-cols-[3fr_1fr] gap-2"> open = false;
<!-- Series field --> toast.success('Updated book metadata!');
<Field.Field> } catch (error) {
<Field.Label for="series">Series</Field.Label> console.error('Error occurred updating book metadata: ', error);
<Input {...updateBookMetadata.fields.series.as('text')} /> toast.error('Failed to update book metadata.');
{#each updateBookMetadata.fields.series.issues() ?? [] as issue} }
<Field.Error>{issue.message}</Field.Error> })}
{/each} class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
</Field.Field> >
<input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
<!-- Series position field --> {@render groupHeading('Identity')}
<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>
<!-- Authors field --> <Field.Field class="col-span-full">
<Field.Field> <Field.Label for="title">Title</Field.Label>
<Field.Label for="authors">Authors</Field.Label> <Input {...updateBookMetadata.fields.title.as('text')} />
<TagsInput {#each updateBookMetadata.fields.title.issues() ?? [] as issue}
bind:value={authors} <Field.Error>{issue.message}</Field.Error>
validate={validateTagsInput} {/each}
placeholder="Add an author" </Field.Field>
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>
<!-- Tags field --> <Field.Field>
<Field.Field> <Field.Label for="subtitle">Subtitle</Field.Label>
<Field.Label for="tags">Tags</Field.Label> <Input {...updateBookMetadata.fields.subtitle.as('text')} />
<TagsInput {#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
bind:value={tags} <Field.Error>{issue.message}</Field.Error>
validate={validateTagsInput} {/each}
placeholder="Add a tag" </Field.Field>
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>
<!-- Description field --> <Field.Field>
<Field.Field> <Field.Label for="edition">Edition</Field.Label>
<Field.Label for="description">Description</Field.Label> <Input {...updateBookMetadata.fields.edition.as('number')} />
<Textarea {...updateBookMetadata.fields.description.as('text')} /> {#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
{#each updateBookMetadata.fields.description.issues() ?? [] as issue} <Field.Error>{issue.message}</Field.Error>
<Field.Error>{issue.message}</Field.Error> {/each}
{/each} </Field.Field>
</Field.Field>
<!-- Identifier fields --> <Field.Field>
<Field.Field> <Field.Label for="series">Series</Field.Label>
<Field.Label for="identifiers">Identifiers</Field.Label> <Input {...updateBookMetadata.fields.series.as('text')} />
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2"> {#each updateBookMetadata.fields.series.issues() ?? [] as issue}
{#each identifierKeys as _, idx} <Field.Error>{issue.message}</Field.Error>
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." /> {/each}
<Input bind:value={identifierValues[idx]} placeholder="Value..." /> </Field.Field>
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}> <div class="grid grid-cols-2 gap-2">
<Minus /> <Field.Field>
</Button> <Field.Label for="series_position">No.</Field.Label>
{/each} <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()}> <Field.Field>
<Plus /> <Field.Label for="language">Language</Field.Label>
Add Identifier <Input {...updateBookMetadata.fields.language.as('text')} />
</Button> {#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" /> {@render groupHeading('People and subjects')}
</div>
</Field.Field>
<!-- Publisher field --> <Field.Field class="col-span-full">
<div class="grid grid-cols-[2fr_1fr] gap-2"> <Field.Label for="authors">Authors</Field.Label>
<Field.Field> <TagsInput
<Field.Label for="publisher">Publisher</Field.Label> bind:value={authors}
<Input {...updateBookMetadata.fields.publisher.as('text')} /> validate={validateTagsInput}
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue} placeholder="Add an author"
<Field.Error>{issue.message}</Field.Error> class="min-h-10 p-2 text-sm"
{/each} />
</Field.Field> {#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 class="col-span-full">
<Field.Field> <Field.Label for="tags">Tags</Field.Label>
<Field.Label for="published_date">Date published</Field.Label> <TagsInput
<Input {...updateBookMetadata.fields.published_date.as('date')} /> bind:value={tags}
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue} validate={validateTagsInput}
<Field.Error>{issue.message}</Field.Error> placeholder="Add a tag"
{/each} class="min-h-10 p-2 text-sm"
</Field.Field> />
</div> {#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"> {@render groupHeading('Publication')}
<!-- 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>
<!-- Language field --> <Field.Field>
<Field.Field> <Field.Label for="publisher">Publisher</Field.Label>
<Field.Label for="language">Language</Field.Label> <Input {...updateBookMetadata.fields.publisher.as('text')} />
<Input {...updateBookMetadata.fields.language.as('text')} /> {#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
{#each updateBookMetadata.fields.language.issues() ?? [] as issue} <Field.Error>{issue.message}</Field.Error>
<Field.Error>{issue.message}</Field.Error> {/each}
{/each} </Field.Field>
</Field.Field>
<!-- Edition field --> <div class="grid grid-cols-2 gap-2">
<Field.Field> <Field.Field>
<Field.Label for="edition">Edition</Field.Label> <Field.Label for="published_date">Published</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} /> <Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue} {#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
</div>
</Field.Group>
</Field.Set>
</Card.Content>
<!-- Submit button --> <Field.Field>
<Card.Footer class="flex-col gap-2 pt-6"> <Field.Label for="pages">Pages</Field.Label>
<Button type="submit" class="w-full">Save</Button> <Input {...updateBookMetadata.fields.pages.as('number')} />
</Card.Footer> {#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
</form> <Field.Error>{issue.message}</Field.Error>
</Card.Root> {/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"> <script lang="ts">
import { resolve } from '$app/paths';
import { Separator } from '$lib/components/ui/separator/index.js'; import { Separator } from '$lib/components/ui/separator/index.js';
import SettingsIcon from '@lucide/svelte/icons/settings'; import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
import UnjsDb0 from '$lib/components/icons/UnjsDb0.svelte';
import NavMain from './nav-main.svelte'; import NavMain from './nav-main.svelte';
import NavUser from './nav-user.svelte';
import LibrarySwitcher from './library-switcher.svelte'; import LibrarySwitcher from './library-switcher.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { page } from '$app/state';
let { let {
ref = $bindable(null), ref = $bindable(null),
collapsible = 'icon', collapsible = 'icon',
user,
...restProps ...restProps
}: ComponentProps<typeof Sidebar.Root> = $props(); }: ComponentProps<typeof Sidebar.Root> & { user: { email: string } } = $props();
const libraryState = getLibraryState(); 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({ const header = $derived({
title: 'chitai', title: 'chitai',
icon: UnjsDb0, url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
url: `/library/${libraryState.activeLibrary!.id}`
});
const footer = $derived({
title: 'Settings',
url: '/settings',
icon: SettingsIcon,
isActive: page.url.pathname.startsWith('/settings')
}); });
</script> </script>
<Sidebar.Root variant="floating" {collapsible} {...restProps} class="ml-1 py-3"> <Sidebar.Root variant="floating" {collapsible} {...restProps} class="ml-1 py-3">
<Sidebar.Header class="h-(--header-height)"> <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 })} {#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}> <a href={header.url} {...props}>
<header.icon class="mr-3 scale-175" /> <ChitaiMark class="mr-3 size-7!" />
<span class="text-xl font-semibold">{header.title}</span> <span class="font-serif text-xl tracking-tight">{header.title}</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
@@ -50,15 +56,6 @@
<NavMain /> <NavMain />
</Sidebar.Content> </Sidebar.Content>
<Sidebar.Footer> <Sidebar.Footer>
<Sidebar.MenuButton class="mb-2" isActive={footer.isActive}> <NavUser {user} />
{#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>
</Sidebar.Footer> </Sidebar.Footer>
</Sidebar.Root> </Sidebar.Root>
@@ -35,6 +35,9 @@
<span class="ml-1 truncate font-semibold"> <span class="ml-1 truncate font-semibold">
{libraryState.activeLibrary!.name} {libraryState.activeLibrary!.name}
</span> </span>
<span class="ml-1 truncate font-mono text-xs tabular-nums text-muted-foreground">
{libraryState.activeLibrary!.total ?? 0} books
</span>
</div> </div>
<ChevronsUpDownIcon class="mr-2 ml-auto" /> <ChevronsUpDownIcon class="mr-2 ml-auto" />
</Sidebar.MenuButton> </Sidebar.MenuButton>
@@ -1,102 +1,137 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { resolve } from '$app/paths';
import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { page } from '$app/state';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { Badge } from "$lib/components/ui/badge/index.js"; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte'; import { Badge } from '$lib/components/ui/badge/index.js';
import { getLibraryState } from '$lib/state/library.svelte'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte'; 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 libraryState = getLibraryState();
const bookshelfState = getBookshelfState(); const bookshelfState = getBookshelfState();
const sidebar = useSidebar();
let items = $derived( // 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.
title: 'Home', let shelvesOpen = $state(false);
url: `/library/${libraryState.activeLibrary?.id}`,
icon: House // 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
title: 'Library', // layout. Only the url and active state need to be reactive, so they are
url: `/library/${libraryState.activeLibrary?.id}/view`, // computed per-item in the markup instead.
icon: LibraryBig //
}, // 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
title: 'Shelves', // it to page.url.pathname would be false on the server and true after
url: '#', // hydration — the highlight would flash in.
icon: Rows3, const items = [
shelves: [] {
} title: 'Home',
].map((item) => ({ icon: House,
...item, routeId: '/(root)/(library)/library/[libraryId]',
isActive: page.url.pathname === item.url 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> </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.Group>
<Sidebar.Menu> <Sidebar.Menu>
{#each items as item (item.title)} {#each items as item (item.title)}
{#if 'shelves' in item} {@const url = item.path(libraryState.activeLibrary?.id)}
<Collapsible.Root open={item.isActive} class="group/collapsible"> {@const isActive = item.routeId !== null && page.route.id === item.routeId}
{#snippet child({ props })} {#if 'shelves' in item}
<Sidebar.MenuItem {...props}> <Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
<Collapsible.Trigger> {#snippet child({ props })}
{#snippet child({ props })} <Sidebar.MenuItem {...props}>
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10"> {#if sidebar.state === 'collapsed'}
{#if item.icon} <!--
<item.icon class="scale-125" /> Sidebar.MenuSub is group-data-[collapsible=icon]:hidden, so
{/if} toggling in the rail opens a list nobody can see. Expand the
<span class="text-md ml-2">{item.title}</span> sidebar and open the section instead of toggling.
<ChevronRightIcon -->
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" <Sidebar.MenuButton
/> tooltipContent={item.title}
</Sidebar.MenuButton> class="h-10"
{/snippet} onclick={() => {
</Collapsible.Trigger> sidebar.setOpen(true);
<Collapsible.Content> shelvesOpen = true;
<Sidebar.MenuSub class="w-full"> }}
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)} >
<Sidebar.MenuSubItem> {@render shelvesTrigger(item)}
<Sidebar.MenuSubButton> </Sidebar.MenuButton>
{#snippet child({ props })} {:else}
<a <Collapsible.Trigger>
href={`/library/${libraryState.activeLibrary!.id}/view?shelves=${shelf.id}`} {#snippet child({ props })}
{...props} <Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
> {@render shelvesTrigger(item)}
<Badge </Sidebar.MenuButton>
variant="outline" {/snippet}
class="scale-90 font-semibold bg-sidebar-primary text-sidebar-primary-foreground mr-1"> </Collapsible.Trigger>
{shelf.total} {/if}
</Badge> <Collapsible.Content>
<span>{shelf.title}</span> <Sidebar.MenuSub class="w-full">
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
<Sidebar.MenuSubItem>
</a> <Sidebar.MenuSubButton>
{/snippet} {#snippet child({ props })}
</Sidebar.MenuSubButton> <a
</Sidebar.MenuSubItem> href="{resolve('/(root)/(library)/library/[libraryId]/view', {
{/each} libraryId: String(libraryState.activeLibrary!.id)
</Sidebar.MenuSub> })}?shelves={shelf.id}"
</Collapsible.Content> {...props}
</Sidebar.MenuItem> >
{/snippet} <Badge
</Collapsible.Root> variant="outline"
{:else} class="mr-1 scale-90 bg-sidebar-primary font-semibold text-sidebar-primary-foreground"
<Sidebar.MenuItem> >
<Sidebar.MenuButton isActive={item.isActive} tooltipContent={item.title} class="h-10"> {shelf.total}
{#snippet child({ props })} </Badge>
<a href={item.url} {...props}> <span>{shelf.title}</span>
{#if item.icon} </a>
<item.icon class="scale-125" /> {/snippet}
{/if} </Sidebar.MenuSubButton>
<span class="text-md pl-2">{item.title}</span> </Sidebar.MenuSubItem>
</a> {/each}
{/snippet} </Sidebar.MenuSub>
</Sidebar.MenuButton> </Collapsible.Content>
</Sidebar.MenuItem> </Sidebar.MenuItem>
{/if} {/snippet}
{/each} </Collapsible.Root>
</Sidebar.Menu> {:else}
</Sidebar.Group> <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"> <script lang="ts">
import { resolve } from '$app/paths';
import * as Command from '$lib/components/ui/command/index'; import * as Command from '$lib/components/ui/command/index';
import * as Kbd from '$lib/components/ui/kbd/index.js'; import * as Kbd from '$lib/components/ui/kbd/index.js';
import * as InputGroup from '$lib/components/ui/input-group/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 { getLibraryState } from '$lib/state/library.svelte';
import type { PaginatedResponse, Book } from '$lib/schema'; import type { PaginatedResponse, Book } from '$lib/schema';
import BookImage from '../view/book-image.svelte'; import BookImage from '../view/book-image.svelte';
import GeneratedCover from '../view/generated-cover.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
const libraryState = getLibraryState(); const libraryState = getLibraryState();
@@ -59,14 +61,14 @@
<svelte:document onkeydown={handleKeydown} /> <svelte:document onkeydown={handleKeydown} />
<form> <form class="min-w-0 flex-1">
<div class="flex w-full max-w-xs flex-col gap-6"> <div class="flex w-full max-w-xl flex-col">
<InputGroup.Root> <InputGroup.Root>
<InputGroup.Input placeholder="Search..." onclick={handleClick} /> <InputGroup.Input placeholder="Search books..." onclick={handleClick} />
<InputGroup.Addon> <InputGroup.Addon>
<SearchIcon /> <SearchIcon />
</InputGroup.Addon> </InputGroup.Addon>
<InputGroup.Addon align="inline-end"> <InputGroup.Addon align="inline-end" class="hidden sm:flex">
<Kbd.Root>Ctrl</Kbd.Root>+ <Kbd.Root>Ctrl</Kbd.Root>+
<Kbd.Root>k</Kbd.Root> <Kbd.Root>k</Kbd.Root>
</InputGroup.Addon> </InputGroup.Addon>
@@ -97,25 +99,33 @@
<Command.Item <Command.Item
value={String(book.id)} value={String(book.id)}
onSelect={() => { onSelect={() => {
goto(`/book/${book.id}`); goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) }));
open = false; open = false;
}} }}
class="cursor-pointer" class="cursor-pointer"
> >
<div class="hover:bg-base-200 flex gap-4 p-3"> <div class="hover:bg-base-200 flex gap-4 p-3">
<BookImage {#if book.cover_image}
src="/api/{book.cover_image}" <BookImage
class="w-24 rounded object-cover shadow-lg" 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"> <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> <span class=" text-md">{book.subtitle}</span>
{#if book.authors.length > 0} {#if book.authors.length > 0}
<span class="line-clamp-1 w-full text-sm text-muted-foreground"> <span class="line-clamp-1 w-full text-sm text-muted-foreground">
by by
{#each book.authors as author} {#each book.authors as author}
<a <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 class="cs-list hover:underline">{author.name}</a
> &thinsp; > &thinsp;
{/each} {/each}
@@ -11,21 +11,18 @@
</script> </script>
<header class="sticky top-0 z-50 flex w-full items-center border-b bg-background"> <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="my-2 flex h-(--header-height) w-full items-center gap-3 px-4">
<div class="flex w-full justify-between"> <Button class="size-8 shrink-0" variant="ghost" size="icon" onclick={sidebar.toggle}>
<div class="flex gap-3"> <SidebarIcon />
<Button class="size-8" variant="ghost" size="icon" onclick={sidebar.toggle}> </Button>
<SidebarIcon /> <Separator orientation="vertical" class="mr-1 h-6" />
</Button>
<Separator orientation="vertical" class="mr-2 h-(--header-height) border" />
</div>
<SearchForm /> <!-- Search takes the free space rather than sitting at a fixed width -->
<SearchForm />
<div class="flex w-24 items-center gap-4"> <div class="ml-auto flex shrink-0 items-center gap-2">
<UploadButton /> <UploadButton />
<ThemeToggle /> <ThemeToggle />
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -1,5 +1,5 @@
<script lang="ts"> <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 * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { buttonVariants } from '$lib/components/ui/button/button.svelte'; import { buttonVariants } from '$lib/components/ui/button/button.svelte';
@@ -9,25 +9,26 @@
let { class: className = '' }: { class?: string } = $props(); let { class: className = '' }: { class?: string } = $props();
</script> </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.Provider>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger <Tooltip.Trigger
onclick={toggleMode} onclick={toggleMode}
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110 {className}" class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110 {className}"
> >
{#if mode.current === 'light'} <Moon class="scale-110 dark:hidden" />
<Moon class="scale-110" /> <Sun class="hidden scale-110 dark:block" />
{:else} <span class="sr-only">Toggle light and dark mode</span>
<Sun class="scale-110" />
{/if}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
{#if mode.current === 'light'} <p class="dark:hidden">Dark Mode</p>
<p>Dark Mode</p> <p class="hidden dark:block">Light Mode</p>
{:else}
<p>Light Mode</p>
{/if}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
@@ -1,29 +1,16 @@
<script> <script>
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import { buttonVariants } from '$lib/components/ui/button/button.svelte'; import { Button } from '$lib/components/ui/button/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import BooksUpload from '$lib/components/forms/books-upload.svelte'; import BooksUpload from '$lib/components/forms/books-upload.svelte';
import { goto } from '$app/navigation';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte'; import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
const bookOps = getBookOperationsState(); const bookOps = getBookOperationsState();
</script> </script>
<Tooltip.Provider> <!-- Labelled, so no tooltip: the button already says what it does. -->
<Tooltip.Root ignoreNonKeyboardFocus> <Button onclick={() => (bookOps.uploadDialogOpen = true)}>
<Tooltip.Trigger <Upload class="size-4" />
onclick={async () => { Upload
bookOps.uploadDialogOpen = true; </Button>
}}
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>
<BooksUpload bind:open={bookOps.uploadDialogOpen} /> <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"> <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 {
import { onDestroy, onMount } from 'svelte'; ChevronLeft,
ChevronRight,
PanelLeft,
RotateCcw,
Settings2,
TriangleAlert
} from '@lucide/svelte';
import { Book, Rendition } from 'epubjs'; import type { FoliateRelocateDetail, FoliateTocItem } from '$foliate/view.js';
import { ProgressReporter } from '$lib/reader/progress';
import '../../../app.css'; import { purgeLegacyLocationCache } from '$lib/reader/legacy-cache';
import * as Sidebar from '$lib/components/ui/sidebar/index'; import { setReaderSettingsState } from '$lib/state/reader-settings.svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index';
import { ChevronDown, ChevronRight, ChevronLeft } from '@lucide/svelte';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Spinner } from '$lib/components/ui/spinner/index'; 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>(); const settingsState = setReaderSettingsState();
let containerWidth = $state(0);
let containerHeight = $state(0);
let isReaderVisible = $state(false);
let hasNextPage = $state(true); // Read once: the reader is remounted per file, so these are fixed for its
let hasPrevPage = $state(false); // 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 file = $state<File>();
let rendition = $state<Rendition>(); let loadError = $state<string | null>(null);
let chapters = $state([]); let isReady = $state(false);
let isMounted = $state(false);
let currentLocation = $state(initialEpubLoc);
let currentProgress = $state(initialProgress);
let toc = $state<FoliateTocItem[]>([]);
let activeTocId = $state<number | null>(null);
let isSidebarOpen = $state(false); let isSidebarOpen = $state(false);
let debounceTimeout = $state<NodeJS.Timeout>(); let isSettingsOpen = $state(false);
let isFixedLayout = $state(false);
// Function to update dimensions let progress = $state(initialFraction);
function updateDimensions() { let viewer = $state<ReturnType<typeof FoliateView>>();
if (epubViewer) {
// Get parent element dimensions
const parent = epubViewer.parentElement;
containerWidth = parent?.clientWidth! * 0.9 - 240; const percent = $derived(Math.round(progress * 100));
containerHeight = window.innerHeight * 0.8;
}
}
// Handle window resize /**
function handleResize() { * Fetches the book ourselves rather than handing foliate the URL.
updateDimensions(); *
if (rendition) { * view.open(url) would route through foliate's fetchFile, which names the File
rendition.resize(containerWidth, containerHeight); * 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 { 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 file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
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);
}
} catch (error) { } 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 () => { function handleRelocate(detail: FoliateRelocateDetail) {
if (browser) { progress = detail.fraction;
// Initial setup activeTocId = detail.tocItem?.id ?? null;
updateDimensions(); reporter.record(detail.fraction, detail.cfi);
}
try { function navigateToChapter(href: string) {
// Fetch the EPUB file // Deliberately leaves the sidebar open: picking a chapter is usually part of
const response = await fetch(bookUrl); // browsing several, and closing it each time made that tedious.
const arrayBuffer = await response.arrayBuffer(); void viewer?.goTo(href);
}
// Create book from array buffer onMount(() => {
book = new Book(); purgeLegacyLocationCache();
await book.open(arrayBuffer, 'binary'); reporter.listen();
void loadFile();
// Generate locations if they do not exist in localStorage return () => reporter.dispose();
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();
}
}); });
</script> </script>
<svelte:document onkeydown={handleKeydown} /> <svelte:head>
<title>{title ? `${title} Chitai` : 'Reader — Chitai'}</title>
</svelte:head>
<Sidebar.Provider bind:open={isSidebarOpen}> <Sidebar.Provider bind:open={isSidebarOpen} class="min-h-0">
<Sidebar.Root class={!isReaderVisible ? 'hidden' : ''}> {#if isReady}
<Sidebar.Header /> <ReaderToc {toc} activeId={activeTocId} onnavigate={navigateToChapter} />
<Sidebar.Content> {/if}
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
<Sidebar.GroupContent> <main class="flex h-screen w-full flex-col overflow-hidden bg-background">
<Sidebar.Menu> <!-- Reader chrome: somewhere to go back to, what you are reading, how far in -->
{#each chapters as chapter} <header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
{#if chapter.subitems.length > 0} <Tooltip.Provider>
<Collapsible.Root class="group/collapsible"> <Tooltip.Root ignoreNonKeyboardFocus>
<div class="flex w-full items-center gap-1"> <Tooltip.Trigger
<Sidebar.MenuItem class="min-w-0 flex-1"> class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
<Sidebar.MenuButton onclick={() => (isSidebarOpen = !isSidebarOpen)}
class="w-full" disabled={!isReady || toc.length === 0}
onclick={async () => await navigateToChapter(chapter)} >
> <PanelLeft class="size-4" />
<span class="block truncate" title={chapter.label}>{chapter.label}</span> </Tooltip.Trigger>
</Sidebar.MenuButton> <Tooltip.Content side="bottom"><p>Chapters</p></Tooltip.Content>
</Sidebar.MenuItem> </Tooltip.Root>
<Collapsible.Trigger class="flex-shrink-0 p-2"> </Tooltip.Provider>
<ChevronDown
class="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" <a
/> href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
</Collapsible.Trigger> class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
</div> {title}
<Collapsible.Content> >
<Sidebar.MenuSub> {title || 'Reader'}
{#each chapter.subitems as subchapter} </a>
<Sidebar.MenuSubItem class="min-w-0">
<Sidebar.MenuButton {#if isReady}
class="w-full" <span class="hidden items-center gap-2 sm:flex">
onclick={async () => await navigateToChapter(subchapter)} <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 class="block truncate" title={subchapter.label} </span>
>{subchapter.label}</span <span class="font-mono text-xs text-muted-foreground tabular-nums">{percent}%</span>
> </span>
</Sidebar.MenuButton> {/if}
</Sidebar.MenuSubItem>
{/each} <Tooltip.Provider>
</Sidebar.MenuSub> <Tooltip.Root ignoreNonKeyboardFocus>
</Collapsible.Content> <Tooltip.Trigger
</Collapsible.Root> class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
{:else} onclick={() => (isSettingsOpen = true)}
<Sidebar.MenuItem class="min-w-0"> disabled={!isReady}
<Sidebar.MenuButton >
class="w-full" <Settings2 class="size-4" />
onclick={async () => await navigateToChapter(chapter)} </Tooltip.Trigger>
> <Tooltip.Content side="bottom"><p>Reading settings</p></Tooltip.Content>
<span class="block truncate">{chapter.label}</span> </Tooltip.Root>
</Sidebar.MenuButton> </Tooltip.Provider>
</Sidebar.MenuItem>
{/if} <ThemeToggle />
{/each} </header>
</Sidebar.Menu>
</Sidebar.GroupContent> {#if loadError}
<Sidebar.Group /> <div class="flex flex-1 items-center justify-center p-6">
</Sidebar.Content> <div class="flex max-w-sm flex-col items-center gap-3 text-center">
<Sidebar.Footer /> <TriangleAlert class="size-8 text-destructive" />
</Sidebar.Root> <h2 class="font-serif text-lg">This book wouldn't open</h2>
<main class="flex w-full overflow-hidden"> <p class="text-sm text-muted-foreground">{loadError}</p>
{#if browser} <div class="mt-2 flex gap-2">
{#if !isReaderVisible} <Button onclick={loadFile}>
<div class="absolute inset-0 flex items-center justify-center"> <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 /> <Spinner />
<span class="text-sm text-muted-foreground">Opening…</span>
</div> </div>
{/if} {/if}
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}"> <div class="min-h-0 flex-1 p-4 {isReady ? '' : 'hidden'}">
<div class="flex"> <!-- The page itself: same colour the book is themed to, so the two
<Sidebar.Trigger /> meet seamlessly and the border reads as the edge of the sheet. -->
</div> <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"> <!-- Overlaid rather than placed beside the page, so they stay within
<ChevronLeft reach of the text. They sit in foliate's own page margin. -->
onclick={prevPage} <Button
class={hasPrevPage variant="ghost"
? 'text-primary/70 hover:cursor-pointer hover:text-primary' size="icon"
: 'text-muted'} class="absolute top-1/2 left-1 size-10 -translate-y-1/2 opacity-40 transition-opacity hover:opacity-100"
/> onclick={() => viewer?.goBack()}
<div aria-label="Previous page"
id="epub-viewer" >
bind:this={epubViewer} <ChevronLeft class="size-5" />
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md" </Button>
></div>
<ChevronRight <Button
onclick={nextPage} variant="ghost"
class={hasNextPage size="icon"
? 'text-primary/70 hover:cursor-pointer hover:text-primary' class="absolute top-1/2 right-1 size-10 -translate-y-1/2 opacity-40 transition-opacity hover:opacity-100"
: 'text-muted'} onclick={() => viewer?.goForward()}
/> aria-label="Next page"
>
<ChevronRight class="size-5" />
</Button>
</div> </div>
</div> </div>
{/if} {/if}
</main> </main>
</Sidebar.Provider> </Sidebar.Provider>
<style> <ReaderSettings bind:open={isSettingsOpen} {isFixedLayout} />
.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>
@@ -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', 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', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline', 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: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
@@ -3,6 +3,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import UploadIcon from '@lucide/svelte/icons/upload'; import UploadIcon from '@lucide/svelte/icons/upload';
import { displaySize } from '.'; import { displaySize } from '.';
@@ -26,11 +27,14 @@
...rest ...rest
}: FileDropZoneProps = $props(); }: FileDropZoneProps = $props();
if (maxFiles !== undefined && fileCount === undefined) { // A one-off sanity check at init, not a reactive concern.
console.warn( untrack(() => {
'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt' if (maxFiles !== undefined && fileCount === undefined) {
); console.warn(
} 'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'
);
}
});
let uploading = $state(false); let uploading = $state(false);
@@ -9,6 +9,7 @@
SIDEBAR_WIDTH_ICON SIDEBAR_WIDTH_ICON
} from './constants.js'; } from './constants.js';
import { setSidebar } from './context.svelte.js'; import { setSidebar } from './context.svelte.js';
import { untrack } from 'svelte';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -36,7 +37,7 @@
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}_${contextKey}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; document.cookie = `${SIDEBAR_COOKIE_NAME}_${contextKey}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
contextKey: contextKey contextKey: untrack(() => contextKey)
}); });
</script> </script>
@@ -2,6 +2,7 @@
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './context.svelte.js'; import { useSidebar } from './context.svelte.js';
import { untrack } from 'svelte';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +14,8 @@
contextKey?: string; contextKey?: string;
} = $props(); } = $props();
const sidebar = useSidebar(contextKey); // contextKey selects which sidebar this rail drives; fixed per instance.
const sidebar = useSidebar(untrack(() => contextKey));
</script> </script>
<button <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} {placeholder}
data-invalid={invalid} data-invalid={invalid}
onkeydown={keydown} 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> </div>
@@ -23,9 +23,16 @@
...restProps ...restProps
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props(); }: 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({ setToggleGroupCtx({
variant, get variant() {
size return variant;
},
get size() {
return size;
}
}); });
</script> </script>
@@ -59,8 +59,7 @@
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading> <DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
<DropdownMenu.Separator /> {#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) as shelf}
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={selectedBooks.every( checked={selectedBooks.every(
(book) => book.lists.findIndex((sh) => sh.id === shelf.id) !== -1 (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 FilterButton from './filter-button.svelte';
import SortButton from './sort-button.svelte'; import SortButton from './sort-button.svelte';
import ViewToggle from './view-toggle.svelte'; import ViewToggle from './view-toggle.svelte';
import PresetChips from './preset-chips.svelte';
import BookTable from './book-table.svelte'; import BookTable from './book-table.svelte';
import BookRows from './book-rows.svelte';
import BatchOperationsToolbar from './batch-operations-toolbar.svelte'; import BatchOperationsToolbar from './batch-operations-toolbar.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte'; import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte'; import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
@@ -22,7 +24,6 @@
const bookCollection = getBookCollectionState(); const bookCollection = getBookCollectionState();
const bookOps = getBookOperationsState(); const bookOps = getBookOperationsState();
let view = $state('grid');
let sentinel = $state<HTMLElement>(); let sentinel = $state<HTMLElement>();
let scrollContainer = $state<HTMLElement | null>(null); 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="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"> <div class="flex w-full items-center py-2">
{#if !selectionState.selectionModeActive} {#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"> <div class="ml-auto">
<SortButton /> <SortButton />
@@ -92,8 +100,10 @@
</div> </div>
{:else if bookCollection.books.length > 0} {:else if bookCollection.books.length > 0}
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-11rem)] w-full px-5 pb-5"> <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} /> <BookGrid books={bookCollection.books} />
{:else if bookCollection.view === 'list'}
<BookRows books={bookCollection.books} />
{:else} {:else}
<BookTable books={bookCollection.books} /> <BookTable books={bookCollection.books} />
{/if} {/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); selectedState.toggleSelection(book);
}} }}
class="{selectedState.isSelected(book.id) class="{selectedState.isSelected(book.id)
? 'scale-110 text-yellow-300' ? 'scale-110 text-star'
: 'scale-75 text-white'} transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300" : 'scale-75 text-white'} transition-all hover:scale-110 hover:cursor-pointer hover:text-star"
/> />
</div> </div>
{#if selectedState.isSelected(book.id)} {#if selectedState.isSelected(book.id)}
@@ -42,7 +42,7 @@
: 'opacity-0'} transition-opacity group-hover:opacity-100" : 'opacity-0'} transition-opacity group-hover:opacity-100"
> >
<Circle <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> </div>
{/if} {/if}
@@ -54,7 +54,7 @@
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<EllipsisVertical <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.Trigger>
<DropdownMenu.Content> <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" class="absolute right-2 bottom-20 z-50 inline-flex items-center opacity-0 transition-opacity group-hover:opacity-100"
> >
<Pencil <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={() => { onclick={() => {
bookOps.bookToEdit = book; bookOps.bookToEdit = book;
bookOps.editDialogOpen = true; bookOps.editDialogOpen = true;
@@ -23,8 +23,10 @@
// Start observing // Start observing
resizeObserver.observe(scrollContainer); resizeObserver.observe(scrollContainer);
// Initial check with delay // Measure synchronously. Reading scrollWidth/clientWidth forces layout, so
setTimeout(updateScrollState, 0); // the numbers are already accurate here — deferring to a macrotask just
// guaranteed one painted frame with the arrows in the wrong state.
updateScrollState();
// Cleanup // Cleanup
return () => { return () => {
@@ -33,12 +35,14 @@
}); });
const scrollLeft = () => { const scrollLeft = () => {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' }); scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll // Update state after scroll
setTimeout(updateScrollState, 100); setTimeout(updateScrollState, 100);
}; };
const scrollRight = () => { const scrollRight = () => {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' }); scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll // Update state after scroll
setTimeout(updateScrollState, 100); setTimeout(updateScrollState, 100);
@@ -61,29 +65,32 @@
{#if books.length > 0} {#if books.length > 0}
<div class="flex w-full flex-col"> <div class="flex w-full flex-col">
<div class="flex items-center"> <div class="flex items-center">
<h1 class="ml-4 text-xl font-semibold">{title}</h1> <h1 class="ml-4 font-serif text-xl font-semibold">{title}</h1>
{#if needsScroll} <!--
<div class="ml-auto"> Always mounted, only hidden. `needsScroll` is measured after mount,
<button so mounting on it made the arrows pop in a frame late and shift the
type="button" header; `invisible` keeps the space reserved from the first paint.
disabled={!canScrollLeft} -->
onclick={scrollLeft} <div class="ml-auto {needsScroll ? '' : 'invisible'}" aria-hidden={!needsScroll}>
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`} <button
aria-label="Scroll Left" type="button"
> disabled={!canScrollLeft}
<ChevronLeft size="20" strokeWidth="3" /> onclick={scrollLeft}
</button> class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
<button aria-label="Scroll Left"
type="button" >
disabled={!canScrollRight} <ChevronLeft size="20" strokeWidth="3" />
onclick={scrollRight} </button>
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`} <button
aria-label="Scroll Right" type="button"
> disabled={!canScrollRight}
<ChevronRight size="20" strokeWidth="3" /> onclick={scrollRight}
</button> class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
</div> aria-label="Scroll Right"
{/if} >
<ChevronRight size="20" strokeWidth="3" />
</button>
</div>
</div> </div>
<div <div
bind:this={scrollContainer} 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 * 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 { 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 { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
import { getLibraryState } from '$lib/state/library.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(); 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> </script>
<Table.Root> <div class="flex flex-col gap-3">
<Table.Header> <!--
<Table.Row> Column picker. Sorting lives on the headers, so this is all the toolbar needs.
<Table.Head></Table.Head>
<Table.Head class="max-w-24 min-w-16">Cover</Table.Head> sticky left-0 + w-fit keeps it against the viewport's left edge while the
<Table.Head>Title</Table.Head> table scrolls sideways. Without it the toolbar is as wide as the table — it
<Table.Head class="max-w-24 min-w-16">Authors</Table.Head> is a sibling inside the ScrollArea's fit-content wrapper — so a right
<Table.Head>Publisher</Table.Head> aligned button ends up off-screen once enough columns are on. This works
<Table.Head>Published Date</Table.Head> where the sticky columns could not, because the toolbar sits outside the
<Table.Head>Pages</Table.Head> table's own overflow-x-auto container.
<Table.Head>Tags</Table.Head> -->
<Table.Head>Identifiers</Table.Head> <div class="sticky left-0 flex w-fit items-center gap-3">
</Table.Row> <span class="font-mono text-xs text-muted-foreground tabular-nums">
</Table.Header> {books.length} shown
<Table.Body> </span>
{#each books as book (book.id)} <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.Row>
<Table.Cell <Table.Head class="w-10">
><Checkbox <Checkbox
checked={selectedState.isSelected(book.id)} checked={allSelected}
onCheckedChange={() => selectedState.toggleSelection(book)} aria-label="Select all loaded books"
/></Table.Cell onCheckedChange={() => {
> if (allSelected) selectionState.deselectAll();
<Table.Cell> else selectionState.selectAll(books);
<BookImage src="/api/{book.cover_image}" /> }}
</Table.Cell> />
<Table.Cell>{book.title}</Table.Cell> </Table.Head>
<Table.Cell> <Table.Head class="w-12"></Table.Head>
{#each book.authors as author}
<a {#each visible as column (column.key)}
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}" <Table.Head class={column.numeric ? 'text-right' : ''}>
class="cs-list hover:underline">{author.name}</a {#if column.sort}
> &thinsp; <!-- Sorting is server-side: the header drives the same orderBy /
{/each} sortOrder state the sort menu uses, so it orders the whole
</Table.Cell> library rather than the pages loaded so far. -->
<Table.Cell>{book.publisher}</Table.Cell> <button
<Table.Cell>{book.published_date}</Table.Cell> type="button"
<Table.Cell>{book.pages}</Table.Cell> class="inline-flex items-center gap-1 hover:text-foreground {bookCollection.orderBy ===
<Table.Cell> column.sort
{#each book.tags as tag} ? 'font-semibold text-foreground'
{tag.name} : ''}"
{/each} onclick={() => bookCollection.updateSort(column.sort!)}
</Table.Cell> >
<Table.Cell> {column.label}
{#each Object.entries(book.identifiers) as [name, value] (value)} {#if bookCollection.orderBy === column.sort}
<span class="cs-list">{name}</span> &thinsp; {#if bookCollection.sortOrder === 'asc'}
{/each} <ArrowUp class="size-3" />
</Table.Cell> {:else}
<ArrowDown class="size-3" />
{/if}
{/if}
</button>
{:else}
{column.label}
{/if}
</Table.Head>
{/each}
</Table.Row> </Table.Row>
{/each} </Table.Header>
</Table.Body>
</Table.Root> <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
> &thinsp;
{/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"> <script lang="ts">
import { resolve } from '$app/paths';
import BookImage from './book-image.svelte'; import BookImage from './book-image.svelte';
import GeneratedCover from './generated-cover.svelte';
import { Progress } from '$lib/components/ui/progress/index'; import { Progress } from '$lib/components/ui/progress/index';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte'; import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import type { Book } from '$lib/schema'; 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 selectionState = getBookSelectionState();
const libraryState = getLibraryState(); const libraryState = getLibraryState();
@@ -24,32 +26,53 @@
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}"> <div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
<!-- Book Cover --> <!-- Book Cover -->
<a <a
href="/book/{book.id}" href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="group relative aspect-9/12 w-full overflow-hidden rounded shadow-lg drop-shadow-lg transition-all duration-200 {selected 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-yellow-300' ? 'ring-2 ring-star'
: ''}" : ''}"
onclick={handleClick} onclick={handleClick}
> >
<BookImage <!--
src="/api/{book.cover_image}" Checked rather than left to the image's onerror: with no cover_image the
class="h-full w-full rounded object-cover transition-all duration-200 group-hover:brightness-50 {selected || src became "/api/undefined", 404'd, and fell back to the generic
darkened default_cover.jpg — which is what made every coverless book look alike in
? 'brightness-50' 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> </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 --> <!-- 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 >{book.title}</a
> >
@@ -57,7 +80,9 @@
<p class="line-clamp-1 w-full text-xs text-muted-foreground"> <p class="line-clamp-1 w-full text-xs text-muted-foreground">
{#each book.authors as author} {#each book.authors as author}
<a <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 class="cs-list hover:underline">{author.name}</a
> &thinsp; > &thinsp;
{/each} {/each}
@@ -20,7 +20,7 @@
> >
<Funnel /> <Funnel />
{#if bookCollection.hasActiveFilters} {#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} {/if}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
@@ -19,7 +19,7 @@
<Collapsible.Trigger {...props}> <Collapsible.Trigger {...props}>
{filter.name} {filter.name}
{#if bookCollection.filters[filter.value].length !== 0} {#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} {/if}
<ChevronRightIcon <ChevronRightIcon
class="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" 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} {#if bookCollection.hasActiveSort}
<div <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> ></div>
{/if} {/if}
<ArrowUpDown /> <ArrowUpDown />
@@ -1,40 +1,54 @@
<script> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index'; import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as ToggleGroup from '$lib/components/ui/toggle-group/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> </script>
<ToggleGroup.Root type="single" bind:value={view} class={className}> <ToggleGroup.Root
<Tooltip.Provider> type="single"
<Tooltip.Root> {value}
<Tooltip.Trigger> onValueChange={(next) => {
{#snippet child({ props })} // The group emits '' when the active item is pressed again; keep the
<ToggleGroup.Item value="grid" aria-label="Toggle grid view" {...props}> // current view rather than leaving the browser with nothing to render.
<LayoutGrid class="size-4" /> if (next) onValueChange(next as BookView);
</ToggleGroup.Item> }}
{/snippet} class={className}
</Tooltip.Trigger> >
<Tooltip.Content> {#each BOOK_VIEWS as view (view)}
<p>Grid view</p> {@const Icon = ICONS[view]}
</Tooltip.Content> <Tooltip.Provider>
</Tooltip.Root> <Tooltip.Root ignoreNonKeyboardFocus>
</Tooltip.Provider> <Tooltip.Trigger>
{#snippet child({ props })}
<Tooltip.Provider> <ToggleGroup.Item value={view} aria-label={LABELS[view]} {...props}>
<Tooltip.Root> <Icon class="size-4" />
<Tooltip.Trigger> </ToggleGroup.Item>
{#snippet child({ props })} {/snippet}
<ToggleGroup.Item value="list" aria-label="Toggle list view" {...props}> </Tooltip.Trigger>
<List class="size-4" /> <Tooltip.Content side="bottom">
</ToggleGroup.Item> <p>{LABELS[view]}</p>
{/snippet} </Tooltip.Content>
</Tooltip.Trigger> </Tooltip.Root>
<Tooltip.Content> </Tooltip.Provider>
<p>List view</p> {/each}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</ToggleGroup.Root> </ToggleGroup.Root>
+79
View File
@@ -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
View File
@@ -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, 01. 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 {}
}
+32
View File
@@ -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;
}
+29
View File
@@ -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 510 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);
}
}
+114
View File
@@ -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();
}
+86
View File
@@ -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)
};
}
+163
View File
@@ -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];
}
+39
View File
@@ -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 type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema';
import { page } from '$app/state'; import { page } from '$app/state';
import { BookOperationsState } from './bookOperations.svelte'; 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 { export class BookCollectionState {
public sortOrder = $state<string>(''); public sortOrder = $state<string>('');
public orderBy = $state<string>(''); public orderBy = $state<string>('');
public view = $state<BookView>('grid');
public filters = $state<Record<string, string[]>>({}); public filters = $state<Record<string, string[]>>({});
readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc'); 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.orderBy = page.url.searchParams.get('orderBy') || 'title';
this.sortOrder = page.url.searchParams.get('sortOrder') || 'asc'; 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 // Construct initial filters based on the page data
this.filters = { this.filters = {
authors: page.url.searchParams.getAll('authors'), 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) { updateSort(sortValue: string) {
if (sortValue === this.orderBy) { if (sortValue === this.orderBy) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
@@ -103,7 +140,8 @@ export class BookCollectionState {
url.searchParams.set('orderBy', this.orderBy); url.searchParams.set('orderBy', this.orderBy);
url.searchParams.set('sortOrder', this.sortOrder); 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()); goto(url.toString());
this.loadNewBooks(); this.loadNewBooks();
@@ -209,6 +247,52 @@ export class BookCollectionState {
this.updateSearchParams(); 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) { isFilterSelected(filter: string, value: string) {
return this.filters[filter]?.includes(value) || false; return this.filters[filter]?.includes(value) || false;
} }
+4 -1
View File
@@ -1,3 +1,4 @@
import { resolve } from '$app/paths';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto, invalidate } from '$app/navigation'; import { goto, invalidate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -31,7 +32,9 @@ export class LibraryState {
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0]; this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
if (browser) { if (browser) {
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString()); 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);
}
+346
View File
@@ -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'))}}`
);
}
+133
View File
@@ -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);
}
+46
View File
@@ -58,6 +58,52 @@ export function formatFileSize(bytes: number, decimals = 1, binary = true) {
return `${formattedSize} ${units[exponent]}`; 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) { export function getFileType(filename: string) {
const parts = filename.split('.'); const parts = filename.split('.');
if (parts.length < 2) return 'UNKNOWN'; if (parts.length < 2) return 'UNKNOWN';
+21
View File
@@ -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.
+37
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+437
View File
@@ -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
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+43
View File
@@ -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
View File
@@ -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
}
}
}
+1
View File
@@ -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
View File
@@ -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"> <script lang="ts">
import { resolve } from '$app/paths';
import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js'; import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
import { CollapsibleText } from '$lib/components/ui/collapsible-text/index.js'; import { CollapsibleText } from '$lib/components/ui/collapsible-text/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import * as Tooltip from '$lib/components/ui/tooltip/index'; import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as Field from '$lib/components/ui/field/index.js'; import * as Field from '$lib/components/ui/field/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js'; import { buttonVariants } from '$lib/components/ui/button/index.js';
import BookImage from '$lib/components/view/book-image.svelte'; import BookCover from '$lib/components/view/book-cover.svelte';
import type { BookFile } from '$lib/schema'; import type { BookFile } from '$lib/schema';
import { import {
Album, Album,
BookOpenCheck, BookOpenCheck,
BookOpenText, BookOpenText,
CalendarDays,
Download, Download,
Link,
NotebookPen,
NotebookText,
Pencil, Pencil,
PlusIcon, PlusIcon,
Tags,
Trash2 Trash2
} from '@lucide/svelte'; } 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 { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js'; import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
import { getLibraryState } from '$lib/state/library.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 Table from '$lib/components/ui/table/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte'; import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte';
import { untrack } from 'svelte';
let { data } = $props(); 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(() => { $effect(() => {
book = data.book; book = data.book;
@@ -47,355 +46,421 @@
let deleteFiles = $state(true); let deleteFiles = $state(true);
let fileDeleteDialogOpen = $state(false); let fileDeleteDialogOpen = $state(false);
let fileToDelete = $state<number>(); 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) { function openBookInReader(file: BookFile) {
const params = { bookId: String(book.id), fileId: String(file.id) };
if (getFileType(file.filename) === 'EPUB') 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') 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> </script>
<div class="mt-4 flex flex-1 flex-col items-center"> <div class="flex h-full flex-col overflow-y-auto">
<div class="ml-8 grid max-w-[900px] grid-cols-[minmax(250px,1fr)_2.5fr] gap-8"> <!-- Jacket band: the accent carries the identity, the cover overlaps its edge -->
<!-- Cover image and action buttons --> <!--
<aside class="flex flex-col gap-4"> A contained card, not a full-bleed banner. Breaking out with -mx-4 left it
<a href="/book/{book.id}" class="w-full rounded shadow-lg drop-shadow-lg"> inset on one side and bleeding off the other, and the negative top margin
<BookImage was clipped by the layout's overflow-hidden. rounded-lg is exactly
src="/api/{book.cover_image}" var(--radius), so the Corners setting reaches it.
class="h-full w-full overflow-hidden rounded object-cover" -->
/> <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} <div class="min-w-0 flex-1">
<Progress <h1 class="font-serif text-3xl leading-tight tracking-tight text-balance">
value={book?.progress.percentage} {book.title}
max={1} </h1>
color={book?.progress.completed ? 'bg-green-600' : 'bg-yellow-500'}
class="mt-[-8px] h-2 rounded {book?.progress.completed {#if book.subtitle}
? '[&>div]:bg-green-600' <p class="mt-1 font-serif text-lg text-primary-foreground/75 italic">{book.subtitle}</p>
: '[&>div]:bg-yellow-500'}"
/>
{/if} {/if}
</a>
<div class="grid grid-cols-[2fr_1fr] gap-2"> {#if book.series}
<!-- If there are multiple files to choose from --> <p class="mt-1 font-mono text-xs tracking-wider text-primary-foreground/70 uppercase">
{#if book.files.length > 1} {book.series.title}{book.series_position ? ` · #${book.series_position}` : ''}
<!-- Read button --> </p>
<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} {/if}
</div>
<div class="grid grid-cols-[1fr_1fr_1fr_1fr] gap-2"> {#if book.authors && book.authors.length > 0}
<!-- Mark as finished button --> <p class="mt-2 line-clamp-1 text-primary-foreground/85">
<Tooltip.Provider> By
<Tooltip.Root> {#each book.authors as author (author.id)}
<Tooltip.Trigger <a
class=" {buttonVariants({ href="{resolve('/(root)/(library)/library/[libraryId]/view', {
variant: 'outline', libraryId: String(libraryState.activeLibrary!.id)
size: 'icon' })}?authors={author.id}"
})} w-full drop-shadow-lg {book?.progress?.completed === true class="cs-list hover:underline">{author.name}</a
? 'text-green-500' > &thinsp;
: ''}" {/each}
onclick={async () => { </p>
if (book?.progress?.completed === true) {/if}
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>
<!-- Add to shelf button --> <!-- Primary actions live on the band, not in a side rail -->
<Tooltip.Provider> <div class="mt-5 flex flex-wrap items-center gap-2">
<Tooltip.Root ignoreNonKeyboardFocus> {#if book.files.length > 1}
<Tooltip.Trigger> <DropdownMenu.Root>
{#snippet child({ props })} <DropdownMenu.Trigger class={bandPrimary}>
<DropdownMenu.Root> <BookOpenText class="size-4" />
<DropdownMenu.Trigger {readLabel}
{...props} </DropdownMenu.Trigger>
class="{buttonVariants({ <DropdownMenu.Content>
variant: 'outline', <DropdownMenu.Group>
size: 'icon' <DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
})} w-full drop-shadow-lg" <DropdownMenu.Separator />
> {#each book.files as file (file.id)}
<Album /> <DropdownMenu.Item onclick={() => openBookInReader(file)}>
</DropdownMenu.Trigger> {getFileType(file.filename)}
<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
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> {/each}
</DropdownMenu.Root> </DropdownMenu.Group>
{/snippet} </DropdownMenu.Content>
</Tooltip.Trigger> </DropdownMenu.Root>
<Tooltip.Content side="bottom">
<p>Add to shelf</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Edit button --> <DropdownMenu.Root>
<Tooltip.Provider> <DropdownMenu.Trigger class={bandGhost}>
<Tooltip.Root ignoreNonKeyboardFocus> <Download class="size-4" />
<Tooltip.Trigger Download
onclick={() => { </DropdownMenu.Trigger>
bookOps.bookToEdit = book; <DropdownMenu.Content>
bookOps.editDialogOpen = true; <DropdownMenu.Group>
}} <DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg" <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 /> <BookOpenText class="size-4" />
</Tooltip.Trigger> {readLabel}
<Tooltip.Content side="bottom"> </button>
<p>Edit</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Delete button --> <button
<Tooltip.Provider> type="button"
<Tooltip.Root ignoreNonKeyboardFocus> class={bandGhost}
<Tooltip.Trigger onclick={() =>
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg" bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
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 /> <Download class="size-4" />
</Tooltip.Trigger> Download
<Tooltip.Content side="bottom"> </button>
<p>Delete</p> {/if}
</Tooltip.Content> </div>
</Tooltip.Root>
</Tooltip.Provider>
</div> </div>
</aside> </div>
</header>
<main class="flex flex-col gap-1"> <!--
<!-- Title, subtitle, and authors --> Body. Source order is description → details → files, which is the mobile
<h1 class="text-2xl font-semibold">{book.title}</h1> reading order asked for. On md+ the explicit row/column placement puts the
details rail on the right and files back under the description.
{#if book?.subtitle} -->
<h2 class="text-lg">{book.subtitle}</h2> <!--
{/if} Same horizontal frame as the band — matching px and max-width, so the
description lines up with the cover's left edge.
{#if book.series} -->
<h2 class="text-base text-muted-foreground"> <div class="px-6 md:px-10">
{book.series.title} <!--
{book.series_position ? `#${book.series_position}` : ''} grid-rows matters here. The rail spans both rows, and with two auto rows the
</h2> browser splits any surplus rail height evenly between them — so a one-line
{/if} 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
{#if book.authors && book.authors.length > 0} free space sends the slack to the bottom instead, where it is invisible.
<h2 class="line-clamp-1 w-full text-lg"> -->
By <div
{#each book.authors as author} 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"
<a >
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}" <!-- Description -->
class="cs-list hover:underline">{author.name}</a <section class="min-w-0 md:col-start-1 md:row-start-1">
> &thinsp;
{/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 -->
{#if book.description} {#if book.description}
<CollapsibleText text={book.description} maxLength={500} /> <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} {/if}
<!-- Book files --> {#if book.lists.length > 0}
<Accordion.Root <div>
type="single" <h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
class="mt-4 w-full rounded-lg bg-muted px-4 shadow-lg drop-shadow " Shelves
> </h2>
<!-- <Separator></Separator> --> <div class="flex flex-wrap gap-1.5">
<Accordion.Item value="item-1"> {#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> <Accordion.Trigger>
<div class="ml-2 flex gap-4"> <div class="flex items-center gap-3">
Library Files Library files
<Badge>{book.files.length}</Badge> <Badge>{book.files.length}</Badge>
</div> </div>
</Accordion.Trigger> </Accordion.Trigger>
@@ -413,14 +478,20 @@
{#each book.files as file (file.id)} {#each book.files as file (file.id)}
<Table.Row> <Table.Row>
<Table.Cell class="max-w-[300px] min-w-0 overflow-hidden"> <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>
<Table.Cell>{formatFileSize(file.size)}</Table.Cell> <Table.Cell class="font-mono text-xs tabular-nums">
<Table.Cell>{getFileType(file.filename)}</Table.Cell> {formatFileSize(file.size)}
</Table.Cell>
<Table.Cell
><span class="font-mono text-xs">{getFileType(file.filename)}</span
></Table.Cell
>
<Table.Cell class="text-right"> <Table.Cell class="text-right">
<div class="flex justify-end gap-1"> <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.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
@@ -428,6 +499,7 @@
variant: 'default', variant: 'default',
size: 'icon' size: 'icon'
})} scale-90" })} scale-90"
onclick={() => openBookInReader(file)}
> >
<BookOpenText /> <BookOpenText />
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -438,7 +510,6 @@
</Tooltip.Provider> </Tooltip.Provider>
{/if} {/if}
<!-- Download button -->
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
@@ -458,8 +529,6 @@
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
<!-- Delete button -->
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
@@ -474,7 +543,7 @@
> >
<Trash2 /> <Trash2 />
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="bottom">Delete File</Tooltip.Content> <Tooltip.Content side="bottom">Delete file</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
</div> </div>
@@ -486,8 +555,8 @@
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
</Accordion.Root> </Accordion.Root>
</div> </section>
</main> </div>
</div> </div>
</div> </div>
@@ -517,11 +586,13 @@
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
<ShelfCreateDialog <ShelfCreateDialog
bind:open={createShelfDialogOpen} bind:open={createShelfDialogOpen}
onSubmit={async (name: string) => { onSubmit={async (name: string) => {
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [book.id]) const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [
book.lists.push(bookshelf) book.id
createShelfDialogOpen = false ]);
book.lists.push(bookshelf);
createShelfDialogOpen = false;
}} }}
/> />
@@ -1,7 +1,4 @@
<script lang="ts"> <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(); let { children } = $props();
</script> </script>

Some files were not shown because too many files have changed in this diff Show More