Compare commits

...
10 Commits
Author SHA1 Message Date
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
35 changed files with 2363 additions and 1245 deletions
+3 -1
View File
@@ -10,9 +10,11 @@ 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`. |
| `frontend/src/lib/vendor/` | Vendored `foliate-js` (the EPUB engine), copied by `frontend/scripts/vendor-foliate.sh`. |
| `frontend/static/pdfjs/` | Vendored pdf.js viewer, used by the PDF reader in an iframe. |
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. | | `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. |
| `docs/screenshots/` | Images used by `README.md`. | | `docs/screenshots/` | Images used by `README.md`. |
| `shell.nix` | Root dev shell; composes the two sub-shells. | | `shell.nix` | Root dev shell; composes the two sub-shells. |
+251
View File
@@ -49,8 +49,259 @@ Worth adding at the same time:
- Deriving ISBN-10 from ISBN-13 when only the latter is present. It is a pure checksum - 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. 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 ## 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 ### Cover dimensions are unknown until load
`book-cover.svelte` renders covers at a fixed height with natural width so nothing is `book-cover.svelte` renders covers at a fixed height with natural width so nothing is
+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.
+6
View File
@@ -29,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: {
-1
View File
@@ -48,7 +48,6 @@
}, },
"dependencies": { "dependencies": {
"construct-style-sheets-polyfill": "^3.1.0", "construct-style-sheets-polyfill": "^3.1.0",
"epubjs": "^0.3.93",
"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"
-218
View File
@@ -11,9 +11,6 @@ importers:
construct-style-sheets-polyfill: construct-style-sheets-polyfill:
specifier: ^3.1.0 specifier: ^3.1.0
version: 3.1.0 version: 3.1.0
epubjs:
specifier: ^0.3.93
version: 0.3.93
mode-watcher: mode-watcher:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0(svelte@5.53.7) version: 1.1.0(svelte@5.53.7)
@@ -899,10 +896,6 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/localforage@0.0.34':
resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==}
deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!
'@types/node@22.19.15': '@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@@ -1008,11 +1001,6 @@ packages:
'@vue/shared@3.5.29': '@vue/shared@3.5.29':
resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
'@xmldom/xmldom@0.7.13':
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -1176,12 +1164,6 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
core-js@3.48.0:
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1197,10 +1179,6 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
debounce-fn@6.0.0: debounce-fn@6.0.0:
resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1262,20 +1240,6 @@ packages:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
epubjs@0.3.93:
resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==}
es5-ext@0.10.64:
resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
engines: {node: '>=0.10'}
es6-iterator@2.0.3:
resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
es6-symbol@3.1.4:
resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==}
engines: {node: '>=0.12'}
esbuild@0.27.3: esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1334,10 +1298,6 @@ packages:
esm-env@1.2.2: esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esniff@2.0.1:
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
engines: {node: '>=0.10'}
espree@10.4.0: espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1367,12 +1327,6 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1478,9 +1432,6 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1493,9 +1444,6 @@ packages:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'} engines: {node: '>=18'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
inline-style-parser@0.2.7: inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -1532,9 +1480,6 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1575,9 +1520,6 @@ packages:
resolution: {integrity: sha512-LoCmV2n7rVry/gD4aMd9No7N3rB6xxxbbJedtdku8Ic7+JYbJRly6GWw+tO28/iuDxzAI0fpcgEoO0JyW+AUPg==} resolution: {integrity: sha512-LoCmV2n7rVry/gD4aMd9No7N3rB6xxxbbJedtdku8Ic7+JYbJRly6GWw+tO28/iuDxzAI0fpcgEoO0JyW+AUPg==}
hasBin: true hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1592,12 +1534,6 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
lie@3.1.1:
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.31.1: lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -1676,9 +1612,6 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
localforage@1.10.0:
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
locate-character@3.0.0: locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@@ -1689,9 +1622,6 @@ packages:
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
lru-cache@11.2.6: lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -1707,9 +1637,6 @@ packages:
resolution: {integrity: sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==} resolution: {integrity: sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
marks-pane@1.0.9:
resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==}
mimic-function@5.0.1: mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1785,9 +1712,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-machine-id@1.1.12: node-machine-id@1.1.12:
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
@@ -1838,9 +1762,6 @@ packages:
package-manager-detector@1.6.0: package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1867,9 +1788,6 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
path-webpack@0.0.3:
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1995,9 +1913,6 @@ packages:
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2006,9 +1921,6 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@4.1.2: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@@ -2066,9 +1978,6 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -2080,9 +1989,6 @@ packages:
set-cookie-parser@3.0.1: set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2129,9 +2035,6 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-ansi@6.0.1: strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2271,9 +2174,6 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'} engines: {node: '>=16'}
type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
typescript-eslint@8.56.1: typescript-eslint@8.56.1:
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3030,10 +2930,6 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/localforage@0.0.34':
dependencies:
localforage: 1.10.0
'@types/node@22.19.15': '@types/node@22.19.15':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -3193,8 +3089,6 @@ snapshots:
'@vue/shared@3.5.29': {} '@vue/shared@3.5.29': {}
'@xmldom/xmldom@0.7.13': {}
acorn-jsx@5.3.2(acorn@8.16.0): acorn-jsx@5.3.2(acorn@8.16.0):
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@@ -3356,10 +3250,6 @@ snapshots:
cookie@0.6.0: {} cookie@0.6.0: {}
core-js@3.48.0: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -3374,11 +3264,6 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d@1.0.2:
dependencies:
es5-ext: 0.10.64
type: 2.7.3
debounce-fn@6.0.0: debounce-fn@6.0.0:
dependencies: dependencies:
mimic-function: 5.0.1 mimic-function: 5.0.1
@@ -3420,36 +3305,6 @@ snapshots:
env-paths@3.0.0: {} env-paths@3.0.0: {}
epubjs@0.3.93:
dependencies:
'@types/localforage': 0.0.34
'@xmldom/xmldom': 0.7.13
core-js: 3.48.0
event-emitter: 0.3.5
jszip: 3.10.1
localforage: 1.10.0
lodash: 4.17.23
marks-pane: 1.0.9
path-webpack: 0.0.3
es5-ext@0.10.64:
dependencies:
es6-iterator: 2.0.3
es6-symbol: 3.1.4
esniff: 2.0.1
next-tick: 1.1.0
es6-iterator@2.0.3:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
es6-symbol: 3.1.4
es6-symbol@3.1.4:
dependencies:
d: 1.0.2
ext: 1.7.0
esbuild@0.27.3: esbuild@0.27.3:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3 '@esbuild/aix-ppc64': 0.27.3
@@ -3559,13 +3414,6 @@ snapshots:
esm-env@1.2.2: {} esm-env@1.2.2: {}
esniff@2.0.1:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
event-emitter: 0.3.5
type: 2.7.3
espree@10.4.0: espree@10.4.0:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@@ -3594,15 +3442,6 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
event-emitter@0.3.5:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
ext@1.7.0:
dependencies:
type: 2.7.3
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
@@ -3693,8 +3532,6 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -3704,8 +3541,6 @@ snapshots:
index-to-position@1.2.0: {} index-to-position@1.2.0: {}
inherits@2.0.4: {}
inline-style-parser@0.2.7: {} inline-style-parser@0.2.7: {}
ip-address@10.1.0: {} ip-address@10.1.0: {}
@@ -3734,8 +3569,6 @@ snapshots:
is-unicode-supported@2.1.0: {} is-unicode-supported@2.1.0: {}
isarray@1.0.0: {}
isexe@2.0.0: {} isexe@2.0.0: {}
jiti@2.6.1: {} jiti@2.6.1: {}
@@ -3805,13 +3638,6 @@ snapshots:
- ws - ws
- zod - zod
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -3825,14 +3651,6 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
lie@3.1.1:
dependencies:
immediate: 3.0.6
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.31.1: lightningcss-android-arm64@1.31.1:
optional: true optional: true
@@ -3884,10 +3702,6 @@ snapshots:
lilconfig@2.1.0: {} lilconfig@2.1.0: {}
localforage@1.10.0:
dependencies:
lie: 3.1.1
locate-character@3.0.0: {} locate-character@3.0.0: {}
locate-path@6.0.0: locate-path@6.0.0:
@@ -3896,8 +3710,6 @@ snapshots:
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash@4.17.23: {}
lru-cache@11.2.6: {} lru-cache@11.2.6: {}
lz-string@1.5.0: {} lz-string@1.5.0: {}
@@ -3922,8 +3734,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
marks-pane@1.0.9: {}
mimic-function@5.0.1: {} mimic-function@5.0.1: {}
minimatch@10.2.4: minimatch@10.2.4:
@@ -3990,8 +3800,6 @@ snapshots:
negotiator@1.0.0: {} negotiator@1.0.0: {}
next-tick@1.1.0: {}
node-machine-id@1.1.12: {} node-machine-id@1.1.12: {}
obug@2.1.1: {} obug@2.1.1: {}
@@ -4054,8 +3862,6 @@ snapshots:
package-manager-detector@1.6.0: {} package-manager-detector@1.6.0: {}
pako@1.0.11: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
@@ -4081,8 +3887,6 @@ snapshots:
lru-cache: 11.2.6 lru-cache: 11.2.6
minipass: 7.1.3 minipass: 7.1.3
path-webpack@0.0.3: {}
pathe@2.0.3: {} pathe@2.0.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
@@ -4140,22 +3944,10 @@ snapshots:
proc-log@6.1.0: {} proc-log@6.1.0: {}
process-nextick-args@2.0.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
react@19.2.0: {} react@19.2.0: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdirp@4.1.2: {} readdirp@4.1.2: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
@@ -4231,8 +4023,6 @@ snapshots:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
safe-buffer@5.1.2: {}
safer-buffer@2.1.2: safer-buffer@2.1.2:
optional: true optional: true
@@ -4240,8 +4030,6 @@ snapshots:
set-cookie-parser@3.0.1: {} set-cookie-parser@3.0.1: {}
setimmediate@1.0.5: {}
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0
@@ -4291,10 +4079,6 @@ snapshots:
get-east-asian-width: 1.5.0 get-east-asian-width: 1.5.0
strip-ansi: 7.2.0 strip-ansi: 7.2.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
strip-ansi@6.0.1: strip-ansi@6.0.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@@ -4449,8 +4233,6 @@ snapshots:
type-fest@4.41.0: {} type-fest@4.41.0: {}
type@2.7.3: {}
typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
@@ -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 = ({ reason, file }: { reason: FileRejectedReason; file: File }) => {
rejected = [...rejected, { name: file.webkitRelativePath || file.name, reason }];
};
/**
* 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) };
} }
};
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => { /**
toast.error(`${file.name} failed to upload!`, { description: reason }); * 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;
function navigateToBooks(books: PaginatedResponse<Book>) { 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" overflow-hidden and the min-w-0 on the body below are what keep a long
>Uploading {uploadBooks.fields.files.value().length} files...</span filename inside the dialog. Dialog.Content is a grid, and grid and flex
> items default to min-width:auto — they refuse to shrink below their
<Spinner class="scale-150" /> content's intrinsic width, so one long name widened the body and pushed it
</div> straight through the dialog's edge regardless of any truncate further down.
{:else} -->
<Dialog.Content class="overflow-hidden sm:max-w-2xl">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Upload Books</Dialog.Title> <Dialog.Title>Add books</Dialog.Title>
<Dialog.Description>
Files or a folder. A folder becomes one book per directory.
</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form <div class="flex w-full min-w-0 flex-col gap-3">
{...uploadBooks.enhance(async ({ submit, form }) => { <div class="flex flex-col gap-1.5">
try { <Field.Label for="library_id">Library</Field.Label>
await submit(); <NativeSelect.Root id="library_id" bind:value={libraryId} class="w-48">
{#each libraryState.libraries as library (library.id)}
// Check if there are any validation issues <NativeSelect.Option value={library.id}>{library.name}</NativeSelect.Option>
const issues = uploadBooks.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
// Update library book count
const count = uploadBooks.result.total
libraryState.libraries.find(lib => uploadBooks.fields.library_id.value() == lib.id.toString())!.total += count
// Reset the files field
uploadBooks.fields.files.set([]);
toast.success('Books successfully uploaded!');
if (navigateOnUpload) {
navigateToBooks(uploadBooks.result);
}
} catch (error) {
console.error('Failed to upload book: ', error);
toast.error('Failed to upload books');
}
})}
bind:this={formEl}
enctype="multipart/form-data"
class="flex w-full flex-col gap-2 p-4"
>
<!-- Library select field -->
<Field.Label for="library_id">Select Library</Field.Label>
<NativeSelect.Root {...uploadBooks.fields.library_id.as('select')} class="w-36">
{#each libraryState.libraries as library}
<NativeSelect.Option value={library.id}>
{library.name}
</NativeSelect.Option>
{/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"
/> />
<input class="hidden" {...uploadBooks.fields.files.as('file multiple')} />
<div class="flex max-h-[300px] flex-col gap-2 overflow-y-auto"> {#if files.length > 0}
{#each files as file, idx} <div class="flex items-baseline justify-between border-b pb-1 text-sm">
<div class="flex place-items-center justify-between gap-2"> <span><strong class="tabular-nums">{files.length}</strong> ready to upload</span>
<div class="flex flex-col"> <span class="font-mono text-xs text-muted-foreground tabular-nums">
<span>{file.name}</span> {displaySize(totalSize)}
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span> </span>
</div>
{/if}
<div class="flex max-h-[300px] min-w-0 flex-col gap-2 overflow-y-auto">
{#each files as file, idx (file.name)}
{@const location = splitPath(file.name)}
<div class="flex min-w-0 items-center justify-between gap-2">
<!-- flex-1 as well as min-w-0: without a constrained width there is
nothing for truncate to act against and the row pushes the dialog wide -->
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm" title={file.name}>{location.name}</span>
<span class="flex min-w-0 items-baseline gap-2 text-xs text-muted-foreground">
{#if location.dir}
<span class="truncate font-mono" title={location.dir}>{location.dir}</span>
{/if}
<span class="shrink-0 tabular-nums">{displaySize(file.size)}</span>
</span>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onclick={() => { class="shrink-0"
uploadBooks.fields.files.set([ onclick={() => (files = files.filter((_, i) => i !== idx))}
...Array.from(files).slice(0, idx),
...Array.from(files).slice(idx + 1)
]);
}}
> >
<X /> <X />
<span class="sr-only">Remove {file.name}</span>
</Button> </Button>
</div> </div>
{/each} {/each}
</div> </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}
{rejected.length === 1 ? 'file was' : 'files were'} skipped
</span>
<Button variant="ghost" size="sm" onclick={() => (showRejected = !showRejected)}>
{showRejected ? 'Hide' : 'Show'}
</Button>
</div> </div>
<div class="flex items-center gap-2"> {#if showRejected}
<Switch bind:checked={navigateOnUpload} /> <ul class="mt-2 flex max-h-32 min-w-0 flex-col gap-1 overflow-y-auto">
<Field.Label for="navigate-to-book">Navigate to book on upload</Field.Label> {#each rejected as entry (entry.name)}
</div> <li class="flex min-w-0 justify-between gap-2 text-xs text-muted-foreground">
</div> <span class="min-w-0 flex-1 truncate" title={entry.name}>
</form> {splitPath(entry.name).name}
</span>
<span class="shrink-0">{entry.reason}</span>
</li>
{/each}
</ul>
{/if} {/if}
</div>
{/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,18 +1,25 @@
<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` This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
changes underneath it as different books are edited. bookToEdit is never changes underneath it as different books are edited. bookToEdit is never
cleared, so {#if book} stays true and the tab forms never unmount — they seed 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 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 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. onto the wrong record. Keying on the id remounts the forms per book.
@@ -20,29 +27,41 @@
<Dialog.Root bind:open> <Dialog.Root bind:open>
{#if book} {#if book}
{#key book.id} {#key book.id}
<Dialog.Content class="sm:max-w-xl"> <Dialog.Content
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]"> class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
<Tabs.List class="grid w-full grid-cols-3"> >
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger> <Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
<Tabs.Trigger value="cover">Cover</Tabs.Trigger> <Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
<Tabs.Trigger value="files">Files</Tabs.Trigger> >
</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"
>
<!-- Cover form --> <EditCover {book} />
<Tabs.Content value="cover">
<EditCover {book} {open} />
</Tabs.Content>
<!-- Add files form -->
<Tabs.Content value="files">
<EditFiles {book} /> <EditFiles {book} />
</Tabs.Content> </aside>
</Tabs.Root>
<div class="min-h-0 overflow-y-auto p-5">
<EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
</div>
</div>
<Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
<!-- Cover and file changes hit the server as they happen, while the
fields wait for Save. Saying so is the cheapest way to stop
Cancel reading as "undo everything". -->
<p class="text-xs text-muted-foreground">Cover and file changes apply immediately</p>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button type="submit" form={METADATA_FORM_ID}>Save changes</Button>
</div>
</Dialog.Footer>
</Dialog.Content> </Dialog.Content>
{/key} {/key}
{/if} {/if}
@@ -1,34 +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>();
// Seeded once per mount — see the key in edit-book.svelte // Seeded once per mount — see the key in edit-book.svelte
let coverImagePreview = $state(untrack(() => `/api/${book.cover_image}`)); let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
let autoUploadOnDrop = $state(true);
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() {
@@ -36,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(() => {
@@ -54,65 +43,40 @@
}); });
</script> </script>
<section class="flex min-w-0 flex-col gap-2">
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Cover</h3>
<BookImage src={coverImagePreview} class="w-full rounded-md border" />
<form <form
bind:this={formEl} bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => { {...updateBookCover.enhance(async ({ submit, form }) => {
try { try {
await submit(); await submit();
form.reset(); form.reset();
open = false; // Deliberately does not close the dialog. The cover is one panel of a
toast.success('Updated book cover!'); // larger form now, and closing here would throw away metadata edits
// the reader has not saved yet.
toast.success('Cover updated');
} catch (error) { } catch (error) {
console.error('Failed to update book cover: ', error); console.error('Failed to update book cover: ', error);
toast.error('Failed to update cover.'); toast.error('Failed to update the cover');
} }
})} })}
enctype="multipart/form-data" enctype="multipart/form-data"
class="grid grid-cols-[1fr_2fr] gap-4 p-6" class="flex flex-col gap-2"
> >
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} /> <input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
<div class="flex flex-col gap-2">
<BookImage src={coverImagePreview} class="w-64 rounded" />
</div>
<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')} />
<div class="flex flex-col gap-2">
{#if updateBookCover.fields.file.value()}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{updateBookCover.fields.file.value().name}</span>
<span class="text-xs text-muted-foreground"
>{displaySize(updateBookCover.fields.file.value().size)}</span
>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
updateBookCover.fields.file.set(undefined);
coverImagePreview = `/api/${book.cover_image}`;
}}
>
<X />
</Button>
</div>
{/if}
</div>
<div class="flex flex-row items-center space-x-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit" disabled={!updateBookCover.fields.file.value()}>Upload</Button>
</div>
</div>
</form> </form>
</section>
@@ -1,98 +1,196 @@
<script lang="ts"> <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>
<section class="flex min-w-0 flex-col gap-2">
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
{#if files.length === 0}
<p class="text-xs text-muted-foreground">
No files yet. Add one below so this book can be read or downloaded.
</p>
{/if}
<ul class="flex flex-col gap-1.5">
{#each files as file (file.id)}
<li class="flex items-center gap-2 rounded-md border bg-background p-2">
<span
class="rounded-sm bg-accent px-1.5 py-0.5 font-mono text-[9px] font-semibold text-accent-foreground"
>
{getFileType(file.filename)}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-xs" title={file.filename}>{file.filename}</span>
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
{formatFileSize(file.size)}
</span>
</span>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
title="Download {file.filename}"
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
>
<Download class="size-3.5" />
<span class="sr-only">Download {file.filename}</span>
</Button>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
title="Remove {file.filename}"
onclick={() => confirmDelete(file)}
>
<Trash2 class="size-3.5" />
<span class="sr-only">Remove {file.filename}</span>
</Button>
</li>
{/each}
{#each pending as file (file.name)}
<li
class="flex items-center gap-2 rounded-md border border-dashed bg-background p-2 text-muted-foreground"
>
<Spinner class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate text-xs">{file.name}</span>
<span class="block font-mono text-[10px] tabular-nums">Uploading…</span>
</span>
</li>
{/each}
</ul>
<form <form
{...uploadBookFiles.enhance(async ({ submit, form }) => { bind:this={formEl}
{...uploadBookFiles.enhance(async ({ submit }) => {
try { try {
await submit(); await submit();
// Check if there are any validation issues
const issues = uploadBookFiles.fields.allIssues(); const issues = uploadBookFiles.fields.allIssues();
if (issues && issues.length > 0) { if (issues && issues.length > 0) return;
return;
}
// Reset the files field // 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([]); uploadBookFiles.fields.files.set([]);
toast.success('Files successfully added!'); toast.success('Files added');
} catch (error) { } catch (error) {
console.error('Failed to upload files: ', error); console.error('Failed to add files: ', error);
toast.error('Failed to upload files'); toast.error('Failed to add files');
} }
})} })}
bind:this={formEl}
enctype="multipart/form-data" enctype="multipart/form-data"
class="flex w-full flex-col gap-2 p-4" class="flex flex-col gap-2"
> >
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} /> <input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
<FileDropZone <FileDropZone
{onUpload} {onUpload}
{onFileRejected} {onFileRejected}
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" label="Add a file"
sublabel="EPUB, PDF or MOBI"
/> />
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} /> </form>
<div class="flex flex-col gap-2"> </section>
{#each files as file, idx}
<div class="flex place-items-center justify-between gap-2"> <AlertDialog.Root bind:open={confirmOpen}>
<div class="flex flex-col"> <AlertDialog.Content>
<span>{file.name}</span> <AlertDialog.Header>
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span> <AlertDialog.Title>Remove {fileToDelete?.filename}?</AlertDialog.Title>
</div> <AlertDialog.Description>
<Button This cannot be undone. The other files on this book are not affected.
variant="outline" </AlertDialog.Description>
size="icon" </AlertDialog.Header>
onclick={() => {
uploadBookFiles.fields.files.set([ <!-- The API takes these as separate outcomes: drop the record, or drop the
...Array.from(files).slice(0, idx), record and the file on disk. Leaving it implicit would mean deleting
...Array.from(files).slice(idx + 1) 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">
<X /> Also delete the file from the filesystem
</Button> </Field.Label>
</div>
{/each}
</div> </div>
<div class="flex flex-row items-center space-x-2"> <AlertDialog.Footer>
<Switch bind:checked={autoUploadOnDrop} /> <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label> <AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={removeFile}>
<Button type="submit" class="ml-auto w-fit">Upload</Button> Remove
</div> </AlertDialog.Action>
</form> </AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,18 +1,23 @@
<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 {
book,
open = $bindable(),
/** Lets the dialog footer own the submit button via the `form` attribute. */
formId
}: { book: Book; open: boolean; formId: string } = $props();
// Seeded once per mount; edit-book.svelte keys this form on book.id so a // 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. // different book gets a fresh form rather than the previous book's values.
@@ -22,7 +27,6 @@
let identifierValues = $state(untrack(() => Object.values(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, ''];
} }
@@ -88,9 +92,17 @@
}); });
</script> </script>
<Card.Root class="w-full "> {#snippet groupHeading(label: string)}
<h3
class="col-span-full mt-2 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
>
{label}
</h3>
{/snippet}
<form <form
{...updateBookMetadata.enhance(async ({ submit, form }) => { id={formId}
{...updateBookMetadata.enhance(async ({ submit }) => {
try { try {
await submit(); await submit();
@@ -101,28 +113,19 @@
} }
open = false; open = false;
book = book;
toast.success('Updated book metadata!'); toast.success('Updated book metadata!');
} catch (error) { } catch (error) {
console.error('Error occurred updating book metadata: ', error); console.error('Error occurred updating book metadata: ', error);
toast.error('Failed to update book metadata.'); toast.error('Failed to update book metadata.');
} }
})} })}
class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
> >
<Card.Content> <input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
<Field.Set>
<Field.Group class="flex flex-col">
<!-- Book ID field -->
<Field.Field class="hidden">
<Field.Label for="book_id">Book ID</Field.Label>
<Input {...updateBookMetadata.fields.book_id.as('text')} />
{#each updateBookMetadata.fields.book_id.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Title field --> {@render groupHeading('Identity')}
<Field.Field>
<Field.Field class="col-span-full">
<Field.Label for="title">Title</Field.Label> <Field.Label for="title">Title</Field.Label>
<Input {...updateBookMetadata.fields.title.as('text')} /> <Input {...updateBookMetadata.fields.title.as('text')} />
{#each updateBookMetadata.fields.title.issues() ?? [] as issue} {#each updateBookMetadata.fields.title.issues() ?? [] as issue}
@@ -130,7 +133,6 @@
{/each} {/each}
</Field.Field> </Field.Field>
<!-- Subtitle field -->
<Field.Field> <Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label> <Field.Label for="subtitle">Subtitle</Field.Label>
<Input {...updateBookMetadata.fields.subtitle.as('text')} /> <Input {...updateBookMetadata.fields.subtitle.as('text')} />
@@ -139,8 +141,14 @@
{/each} {/each}
</Field.Field> </Field.Field>
<div class="grid grid-cols-[3fr_1fr] gap-2"> <Field.Field>
<!-- Series field --> <Field.Label for="edition">Edition</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field> <Field.Field>
<Field.Label for="series">Series</Field.Label> <Field.Label for="series">Series</Field.Label>
<Input {...updateBookMetadata.fields.series.as('text')} /> <Input {...updateBookMetadata.fields.series.as('text')} />
@@ -149,18 +157,27 @@
{/each} {/each}
</Field.Field> </Field.Field>
<!-- Series position field --> <div class="grid grid-cols-2 gap-2">
<Field.Field> <Field.Field>
<Field.Label for="series_position">Series position</Field.Label> <Field.Label for="series_position">No.</Field.Label>
<Input {...updateBookMetadata.fields.series_position.as('text')} /> <Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue} {#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
<Field.Field>
<Field.Label for="language">Language</Field.Label>
<Input {...updateBookMetadata.fields.language.as('text')} />
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div> </div>
<!-- Authors field --> {@render groupHeading('People and subjects')}
<Field.Field>
<Field.Field class="col-span-full">
<Field.Label for="authors">Authors</Field.Label> <Field.Label for="authors">Authors</Field.Label>
<TagsInput <TagsInput
bind:value={authors} bind:value={authors}
@@ -176,8 +193,7 @@
{/each} {/each}
</Field.Field> </Field.Field>
<!-- Tags field --> <Field.Field class="col-span-full">
<Field.Field>
<Field.Label for="tags">Tags</Field.Label> <Field.Label for="tags">Tags</Field.Label>
<TagsInput <TagsInput
bind:value={tags} bind:value={tags}
@@ -193,39 +209,8 @@
{/each} {/each}
</Field.Field> </Field.Field>
<!-- Description field --> {@render groupHeading('Publication')}
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Identifier fields -->
<Field.Field>
<Field.Label for="identifiers">Identifiers</Field.Label>
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2">
{#each identifierKeys as _, idx}
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." />
<Input bind:value={identifierValues[idx]} placeholder="Value..." />
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}>
<Minus />
</Button>
{/each}
<Button variant="outline" onclick={() => handleAddIdentifier()}>
<Plus />
Add Identifier
</Button>
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
</div>
</Field.Field>
<!-- Publisher field -->
<div class="grid grid-cols-[2fr_1fr] gap-2">
<Field.Field> <Field.Field>
<Field.Label for="publisher">Publisher</Field.Label> <Field.Label for="publisher">Publisher</Field.Label>
<Input {...updateBookMetadata.fields.publisher.as('text')} /> <Input {...updateBookMetadata.fields.publisher.as('text')} />
@@ -234,18 +219,15 @@
{/each} {/each}
</Field.Field> </Field.Field>
<!-- Published date field --> <div class="grid grid-cols-2 gap-2">
<Field.Field> <Field.Field>
<Field.Label for="published_date">Date published</Field.Label> <Field.Label for="published_date">Published</Field.Label>
<Input {...updateBookMetadata.fields.published_date.as('date')} /> <Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.published_date.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>
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
<!-- Pages field -->
<Field.Field> <Field.Field>
<Field.Label for="pages">Pages</Field.Label> <Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} /> <Input {...updateBookMetadata.fields.pages.as('number')} />
@@ -253,32 +235,44 @@
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
<!-- Language field -->
<Field.Field>
<Field.Label for="language">Language</Field.Label>
<Input {...updateBookMetadata.fields.language.as('text')} />
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Edition field -->
<Field.Field>
<Field.Label for="edition">Edition</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div> </div>
</Field.Group>
</Field.Set>
</Card.Content>
<!-- Submit button --> <Field.Field class="col-span-full">
<Card.Footer class="flex-col gap-2 pt-6"> <Field.Label for="identifiers">Identifiers</Field.Label>
<Button type="submit" class="w-full">Save</Button> <div class="flex flex-col gap-2">
</Card.Footer> {#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> </form>
</Card.Root>
@@ -1,4 +1,5 @@
<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 ChitaiMark from '$lib/components/icons/chitai-mark.svelte'; import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
@@ -27,7 +28,7 @@
// directly in the markup keeps it static. // directly in the markup keeps it static.
const header = $derived({ const header = $derived({
title: 'chitai', title: 'chitai',
url: `/library/${libraryState.activeLibrary!.id}` url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
}); });
</script> </script>
@@ -40,6 +41,8 @@
--> -->
<Sidebar.MenuButton class="mt-1 -ml-1.5"> <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}>
<ChitaiMark class="mr-3 size-7!" /> <ChitaiMark class="mr-3 size-7!" />
<span class="font-serif text-xl tracking-tight">{header.title}</span> <span class="font-serif text-xl tracking-tight">{header.title}</span>
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js"; import { Badge } from '$lib/components/ui/badge/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte'; import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
@@ -22,10 +23,25 @@
// rebuilt on every navigation the icons would remount, flashing and shifting // rebuilt on every navigation the icons would remount, flashing and shifting
// layout. Only the url and active state need to be reactive, so they are // layout. Only the url and active state need to be reactive, so they are
// computed per-item in the markup instead. // computed per-item in the markup instead.
//
// Active state is matched on route id rather than pathname. resolve() returns
// an absolute path on the client but a relative one during SSR, so comparing
// it to page.url.pathname would be false on the server and true after
// hydration — the highlight would flash in.
const items = [ const items = [
{ title: 'Home', icon: House, path: (id?: number) => `/library/${id}` }, {
{ title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` }, title: 'Home',
{ title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] } icon: House,
routeId: '/(root)/(library)/library/[libraryId]',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
},
{
title: 'Library',
icon: LibraryBig,
routeId: '/(root)/(library)/library/[libraryId]/view',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
},
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
]; ];
</script> </script>
@@ -41,7 +57,7 @@
<Sidebar.Menu> <Sidebar.Menu>
{#each items as item (item.title)} {#each items as item (item.title)}
{@const url = item.path(libraryState.activeLibrary?.id)} {@const url = item.path(libraryState.activeLibrary?.id)}
{@const isActive = page.url.pathname === url} {@const isActive = item.routeId !== null && page.route.id === item.routeId}
{#if 'shelves' in item} {#if 'shelves' in item}
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible"> <Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
{#snippet child({ props })} {#snippet child({ props })}
@@ -78,17 +94,18 @@
<Sidebar.MenuSubButton> <Sidebar.MenuSubButton>
{#snippet child({ props })} {#snippet child({ props })}
<a <a
href={`/library/${libraryState.activeLibrary!.id}/view?shelves=${shelf.id}`} href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
})}?shelves={shelf.id}"
{...props} {...props}
> >
<Badge <Badge
variant="outline" variant="outline"
class="scale-90 font-semibold bg-sidebar-primary text-sidebar-primary-foreground mr-1"> class="mr-1 scale-90 bg-sidebar-primary font-semibold text-sidebar-primary-foreground"
>
{shelf.total} {shelf.total}
</Badge> </Badge>
<span>{shelf.title}</span> <span>{shelf.title}</span>
</a> </a>
{/snippet} {/snippet}
</Sidebar.MenuSubButton> </Sidebar.MenuSubButton>
@@ -101,8 +118,10 @@
</Collapsible.Root> </Collapsible.Root>
{:else} {:else}
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive} tooltipContent={item.title} class="h-10"> <Sidebar.MenuButton {isActive} tooltipContent={item.title} class="h-10">
{#snippet child({ props })} {#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}> <a href={url} {...props}>
{#if item.icon} {#if item.icon}
<item.icon class="scale-125" /> <item.icon class="scale-125" />
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import * as Avatar from '$lib/components/ui/avatar/index.js'; import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
@@ -31,13 +32,16 @@
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<Avatar.Root class="size-8 rounded-lg"> <Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> <Avatar.Fallback
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
{initials} {initials}
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span> <span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span> <span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
>
</div> </div>
<ChevronsUpDownIcon class="ml-auto size-4" /> <ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton> </Sidebar.MenuButton>
@@ -53,13 +57,16 @@
<DropdownMenu.Label class="p-0 font-normal"> <DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg"> <Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> <Avatar.Fallback
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
{initials} {initials}
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span> <span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span> <span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
>
</div> </div>
</div> </div>
</DropdownMenu.Label> </DropdownMenu.Label>
@@ -67,11 +74,11 @@
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.Item onSelect={() => goto('/settings/account')}> <DropdownMenu.Item onSelect={() => goto(resolve('/settings/account'))}>
<SettingsIcon class="size-4" /> <SettingsIcon class="size-4" />
Settings Settings
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goto('/settings/appearance')}> <DropdownMenu.Item onSelect={() => goto(resolve('/settings/appearance'))}>
<PaletteIcon class="size-4" /> <PaletteIcon class="size-4" />
Appearance Appearance
</DropdownMenu.Item> </DropdownMenu.Item>
@@ -82,7 +89,7 @@
<DropdownMenu.Item <DropdownMenu.Item
onSelect={async () => { onSelect={async () => {
await logout(); await logout();
await goto('/login'); await goto(resolve('/login'));
}} }}
> >
<LogOutIcon class="size-4" /> <LogOutIcon class="size-4" />
@@ -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();
@@ -97,16 +99,22 @@
<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">
{#if book.cover_image}
<BookImage <BookImage
src="/api/{book.cover_image}" src="/api/{book.cover_image}"
class="w-24 rounded object-cover shadow-lg" 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="font-serif text-lg">{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>
@@ -115,7 +123,9 @@
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}
@@ -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,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { import {
@@ -25,14 +26,14 @@
import ReaderToc from './reader-toc.svelte'; import ReaderToc from './reader-toc.svelte';
let { let {
bookUrl, fileUrl,
bookId, bookId,
filename, filename,
title = '', title = '',
initialProgress = 0, initialProgress = 0,
initialEpubLoc = null initialEpubLoc = null
}: { }: {
bookUrl: string; fileUrl: string;
bookId: string | number; bookId: string | number;
filename: string; filename: string;
title?: string; title?: string;
@@ -78,7 +79,7 @@
file = undefined; file = undefined;
try { try {
const response = await fetch(bookUrl); const response = await fetch(fileUrl);
if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`); if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`);
file = new File([await response.blob()], filename, { type: 'application/epub+zip' }); file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
@@ -135,7 +136,7 @@
</Tooltip.Provider> </Tooltip.Provider>
<a <a
href="/book/{bookId}" href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline" class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
{title} {title}
> >
@@ -178,7 +179,10 @@
<RotateCcw class="size-4" /> <RotateCcw class="size-4" />
Try again Try again
</Button> </Button>
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}> <a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
class={buttonVariants({ variant: 'outline' })}
>
Back to book Back to book
</a> </a>
</div> </div>
@@ -104,7 +104,46 @@
doc.addEventListener('keydown', onKeydown); 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) { function onKeydown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) {
forwardShortcut(event);
return;
}
switch (event.key) { switch (event.key) {
case 'ArrowLeft': case 'ArrowLeft':
case 'PageUp': case 'PageUp':
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Book } from '$lib/schema'; import type { Book } from '$lib/schema';
import GeneratedCover from './generated-cover.svelte';
let { let {
book, book,
@@ -10,12 +11,6 @@
let failed = $state(false); let failed = $state(false);
const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null); const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null);
// A stable hue per title, so a book with no artwork still gets its own
// colour rather than every placeholder looking identical.
const hue = $derived([...book.title].reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) % 360, 7));
const authors = $derived(book.authors.map((a) => a.name).join(', '));
</script> </script>
<!-- <!--
@@ -65,20 +60,10 @@
{:else} {:else}
<!-- No cover on record, or the file is missing. Draw one. --> <!-- No cover on record, or the file is missing. Draw one. -->
<span <span
class="flex h-full flex-col justify-between overflow-hidden rounded-sm p-3 text-left shadow-lg" class="h-full overflow-hidden rounded-sm shadow-lg"
style="width: {Math.round(height * 0.66)}px; background: linear-gradient(152deg, hsl({hue} style="width: {Math.round(height * 0.66)}px;"
30% 34%), hsl({hue} 38% 18%));"
> >
<span <GeneratedCover {book} />
class="line-clamp-4 font-serif text-xs leading-tight"
style="color: hsl({hue} 38% 95%);">{book.title}</span
>
{#if authors}
<span
class="line-clamp-2 font-mono text-[8px] tracking-wider uppercase"
style="color: hsl({hue} 24% 78%);">{authors}</span
>
{/if}
</span> </span>
{/if} {/if}
</span> </span>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import BookCover from './book-cover.svelte'; import BookCover from './book-cover.svelte';
import BookActionsMenu from './book-actions-menu.svelte'; import BookActionsMenu from './book-actions-menu.svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index'; import * as Tooltip from '$lib/components/ui/tooltip/index';
@@ -120,7 +121,7 @@
? 'cursor-pointer' ? 'cursor-pointer'
: ''}" : ''}"
> >
<a href="/book/{book.id}" class="shrink-0"> <a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })} class="shrink-0">
<BookCover {book} height={110} /> <BookCover {book} height={110} />
</a> </a>
@@ -128,7 +129,7 @@
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<a <a
href="/book/{book.id}" href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="line-clamp-2 font-serif text-sm leading-snug hover:underline" class="line-clamp-2 font-serif text-sm leading-snug hover:underline"
> >
{book.title} {book.title}
@@ -166,7 +167,9 @@
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)} {#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
<a <a
data-row-control data-row-control
href="/library/{libraryState.activeLibrary?.id}/view?tags={tag.id}" href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary?.id ?? '')
})}?tags={tag.id}"
class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a
> >
{/each} {/each}
@@ -1,4 +1,5 @@
<script lang="ts"> <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 * 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';
@@ -52,7 +53,9 @@
const visible = $derived(columns.filter((c) => c.on)); const visible = $derived(columns.filter((c) => c.on));
const allSelected = $derived(books.length > 0 && books.every((b) => selectionState.isSelected(b.id))); const allSelected = $derived(
books.length > 0 && books.every((b) => selectionState.isSelected(b.id))
);
/** What a record is lacking — the reason this view exists. */ /** What a record is lacking — the reason this view exists. */
function missing(book: Book) { function missing(book: Book) {
@@ -208,19 +211,26 @@
/> />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<a href="/book/{book.id}"><BookCover {book} height={36} /></a> <a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
><BookCover {book} height={36} /></a
>
</Table.Cell> </Table.Cell>
{#each visible as column (column.key)} {#each visible as column (column.key)}
{#if column.key === 'title'} {#if column.key === 'title'}
<Table.Cell class="max-w-[280px] truncate font-serif"> <Table.Cell class="max-w-[280px] truncate font-serif">
<a href="/book/{book.id}" class="hover:underline">{book.title}</a> <a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="hover:underline">{book.title}</a
>
</Table.Cell> </Table.Cell>
{:else if column.key === 'authors'} {:else if column.key === 'authors'}
<Table.Cell class="max-w-[180px] truncate"> <Table.Cell class="max-w-[180px] truncate">
{#each book.authors as author (author.id)} {#each book.authors as author (author.id)}
<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}
@@ -248,7 +258,7 @@
{formatFileSize(totalSize(book))} {formatFileSize(totalSize(book))}
</Table.Cell> </Table.Cell>
{:else if column.key === 'added'} {:else if column.key === 'added'}
<Table.Cell class="font-mono text-xs tabular-nums text-muted-foreground"> <Table.Cell class="font-mono text-xs text-muted-foreground tabular-nums">
{addedOn(book)} {addedOn(book)}
</Table.Cell> </Table.Cell>
{:else if column.key === 'progress'} {:else if column.key === 'progress'}
@@ -263,7 +273,7 @@
style="width: {Math.round(book.progress.percentage * 100)}%;" style="width: {Math.round(book.progress.percentage * 100)}%;"
></span> ></span>
</span> </span>
<span class="font-mono text-xs tabular-nums text-muted-foreground"> <span class="font-mono text-xs text-muted-foreground tabular-nums">
{Math.round(book.progress.percentage * 100)}% {Math.round(book.progress.percentage * 100)}%
</span> </span>
</span> </span>
@@ -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,12 +26,19 @@
<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-sm 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-star' ? 'ring-2 ring-star'
: ''}" : ''}"
onclick={handleClick} onclick={handleClick}
> >
<!--
Checked rather than left to the image's onerror: with no cover_image the
src became "/api/undefined", 404'd, and fell back to the generic
default_cover.jpg — which is what made every coverless book look alike in
the grid.
-->
{#if book.cover_image}
<BookImage <BookImage
src="/api/{book.cover_image}" src="/api/{book.cover_image}"
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected || class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
@@ -37,6 +46,16 @@
? 'brightness-50' ? '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 <!-- Inside the anchor, which already clips to rounded-sm, so the bar
follows the cover's corners instead of floating below it --> follows the cover's corners instead of floating below it -->
@@ -51,7 +70,9 @@
</a> </a>
<!-- Book Title --> <!-- Book Title -->
<a href="/book/{book.id}" class="text-base-content mt-1 line-clamp-2 w-full font-serif 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
> >
@@ -59,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}
@@ -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>
@@ -99,6 +99,9 @@ export class BookCollectionState {
const url = new URL(page.url); const url = new URL(page.url);
url.searchParams.set('view', next); 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); replaceState(url, page.state);
} }
@@ -137,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();
@@ -285,9 +289,7 @@ export class BookCollectionState {
return wanted.every(([key, values]) => { return wanted.every(([key, values]) => {
const current = this.filters[key] ?? []; const current = this.filters[key] ?? [];
return ( return current.length === values.length && values.every((value) => current.includes(value));
current.length === values.length && values.every((value) => current.includes(value))
);
}); });
} }
+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,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);
}
@@ -1,4 +1,5 @@
<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';
@@ -17,12 +18,7 @@
PlusIcon, PlusIcon,
Trash2 Trash2
} from '@lucide/svelte'; } from '@lucide/svelte';
import { import { describeIdentifier, formatFileSize, getFileType, sortIdentifiers } from '$lib/utils.js';
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';
@@ -65,12 +61,24 @@
: 'Read' : '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. // On the band, the accent is the ground — invert the buttons against it.
@@ -101,7 +109,7 @@
</h1> </h1>
{#if book.subtitle} {#if book.subtitle}
<p class="mt-1 font-serif text-lg italic text-primary-foreground/75">{book.subtitle}</p> <p class="mt-1 font-serif text-lg text-primary-foreground/75 italic">{book.subtitle}</p>
{/if} {/if}
{#if book.series} {#if book.series}
@@ -115,7 +123,9 @@
By By
{#each book.authors as author (author.id)} {#each book.authors as author (author.id)}
<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}
@@ -169,7 +179,11 @@
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
{:else if book.files.length === 1} {:else if book.files.length === 1}
<button type="button" class={bandPrimary} onclick={() => openBookInReader(book.files[0])}> <button
type="button"
class={bandPrimary}
onclick={() => openBookInReader(book.files[0])}
>
<BookOpenText class="size-4" /> <BookOpenText class="size-4" />
{readLabel} {readLabel}
</button> </button>
@@ -184,7 +198,6 @@
Download Download
</button> </button>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
@@ -267,10 +280,11 @@
</dt> </dt>
<dd class="m-0 font-mono break-all tabular-nums"> <dd class="m-0 font-mono break-all tabular-nums">
{#if id.href} {#if id.href}
<!-- Always an absolute URL off-site: openlibrary, doi.org or amazon. -->
<a <a
href={id.href} href={id.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="external noopener noreferrer"
class="hover:text-primary hover:underline">{value}</a class="hover:text-primary hover:underline">{value}</a
> >
{:else} {:else}
@@ -288,7 +302,14 @@
</h2> </h2>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each book.tags as tag (tag.id)} {#each book.tags as tag (tag.id)}
<a href="/tag/{tag.id}" class={badgeVariants({ variant: 'default' })}>{tag.name}</a> <!-- 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} {/each}
</div> </div>
</div> </div>
@@ -302,7 +323,9 @@
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each book.lists as shelf (shelf.id)} {#each book.lists as shelf (shelf.id)}
<a <a
href="/library/{libraryState.activeLibrary!.id}/view?shelves={shelf.id}" href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
})}?shelves={shelf.id}"
class={badgeVariants({ variant: 'outline' })}>{shelf.title}</a class={badgeVariants({ variant: 'outline' })}>{shelf.title}</a
> >
{/each} {/each}
@@ -462,7 +485,10 @@
<Table.Cell class="font-mono text-xs tabular-nums"> <Table.Cell class="font-mono text-xs tabular-nums">
{formatFileSize(file.size)} {formatFileSize(file.size)}
</Table.Cell> </Table.Cell>
<Table.Cell><span class="font-mono text-xs">{getFileType(file.filename)}</span></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">
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'} {#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
@@ -8,11 +8,11 @@
const bookId = page.params.bookId!; const bookId = page.params.bookId!;
// Fetched by the browser through the proxy, which attaches the auth header // Fetched by the browser through the proxy, which attaches the auth header
const bookUrl = `/api/books/download/${bookId}/${fileId}`; const fileUrl = `/api/books/download/${bookId}/${fileId}`;
</script> </script>
<EpubReader <EpubReader
{bookUrl} {fileUrl}
{bookId} {bookId}
title={data.title} title={data.title}
filename={data.filename} filename={data.filename}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js'; 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';
@@ -55,7 +56,7 @@
<!-- Same chrome as the EPUB reader, so leaving works the same way in both --> <!-- Same chrome as the EPUB reader, so leaving works the same way in both -->
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3"> <header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<a <a
href="/book/{bookId}" href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline" class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
title={data?.title} title={data?.title}
> >
@@ -76,7 +77,10 @@
<RotateCcw class="size-4" /> <RotateCcw class="size-4" />
Try again Try again
</Button> </Button>
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}> <a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
class={buttonVariants({ variant: 'outline' })}
>
Back to book Back to book
</a> </a>
</div> </div>
+10
View File
@@ -2,12 +2,14 @@
import { listBookshelves } from '$lib/api'; import { listBookshelves } from '$lib/api';
import AppSidebar from '$lib/components/layout/app-sidebar.svelte'; import AppSidebar from '$lib/components/layout/app-sidebar.svelte';
import SiteHeader from '$lib/components/layout/site-header.svelte'; import SiteHeader from '$lib/components/layout/site-header.svelte';
import UploadTray from '$lib/components/layout/upload-tray.svelte';
import { Loading } from '$lib/components/ui/command'; import { Loading } from '$lib/components/ui/command';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type { Library, PaginatedResponse } from '$lib/schema'; import type { Library, PaginatedResponse } from '$lib/schema';
import { setBookOperationsState } from '$lib/state/bookOperations.svelte'; import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
import { setBookshelfState } from '$lib/state/bookshelf.svelte'; import { setBookshelfState } from '$lib/state/bookshelf.svelte';
import { setLibraryState } from '$lib/state/library.svelte.js'; import { setLibraryState } from '$lib/state/library.svelte.js';
import { setUploadQueueState } from '$lib/state/upload-queue.svelte';
import { setThemeState } from '$lib/theme/theme.svelte'; import { setThemeState } from '$lib/theme/theme.svelte';
import type { ThemeConfig } from '$lib/theme/presets'; import type { ThemeConfig } from '$lib/theme/presets';
@@ -30,6 +32,10 @@
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id); const bookOps = setBookOperationsState(libraryState.activeLibrary!.id);
const theme = setThemeState(untrack(() => data.theme)); const theme = setThemeState(untrack(() => data.theme));
// Set here rather than beside the upload dialog so a running import survives
// the dialog closing and any navigation within the app shell.
setUploadQueueState();
// Inline custom properties live on :root and so are mode-blind. When the // Inline custom properties live on :root and so are mode-blind. When the
// light/dark switch flips, rewrite them for the mode now showing. // light/dark switch flips, rewrite them for the mode now showing.
$effect(() => { $effect(() => {
@@ -52,3 +58,7 @@
</div> </div>
</Sidebar.Provider> </Sidebar.Provider>
</div> </div>
<!-- Outside the sidebar shell: an import keeps running while you browse, so the
tray must not sit anywhere a page swap can take away. -->
<UploadTray />
@@ -1,25 +1,19 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths';
let { children } = $props(); let { children } = $props();
// Route ids rather than paths: resolve() is called in the markup so it stays a
// direct call the lint rule can see, and the active check compares route ids.
// A pathname comparison would miss during SSR, where resolve() returns a
// relative path, and only settle after hydration.
const items = [ const items = [
{ { title: 'Account', routeId: '/(root)/settings/account' },
title: 'Account', { title: 'Appearance', routeId: '/(root)/settings/appearance' },
url: '/settings/account' { title: 'Libraries', routeId: '/(root)/settings/libraries' },
}, { title: 'Devices', routeId: '/(root)/settings/devices' }
{ ] as const;
title: 'Appearance',
url: '/settings/appearance'
},
{
title: 'Libraries',
url: '/settings/libraries'
},
{
title: 'Devices',
url: '/settings/devices'
}
];
</script> </script>
<div class="flex h-[calc(100vh-var(--header-height)-2rem)] flex-col"> <div class="flex h-[calc(100vh-var(--header-height)-2rem)] flex-col">
@@ -28,8 +22,9 @@
<nav class="flex w-48 shrink-0 flex-col gap-1"> <nav class="flex w-48 shrink-0 flex-col gap-1">
{#each items as item} {#each items as item}
<a <a
href={item.url} href={resolve(item.routeId)}
class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page.url.pathname.endsWith(item.url) class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page
.route.id === item.routeId
? 'bg-muted' ? 'bg-muted'
: 'text-muted-foreground'}" : 'text-muted-foreground'}"
> >
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { logout } from '$lib/api'; import { logout } from '$lib/api';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
@@ -12,7 +13,7 @@
<Button <Button
onclick={async () => { onclick={async () => {
await logout(); await logout();
goto('/login'); goto(resolve('/login'));
}} }}
variant="outline" variant="outline"
class="w-32" class="w-32"
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
@@ -36,7 +37,10 @@
>{library.name[0]}</Table.Cell >{library.name[0]}</Table.Cell
> >
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<a href={`/library/${library.id}`} class="hover:underline">{library.name}</a> <a
href={resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(library.id) })}
class="hover:underline">{library.name}</a
>
</Table.Cell> </Table.Cell>
<Table.Cell class="w-16 text-center"> <Table.Cell class="w-16 text-center">
<EllipsisVertical class="scale-75" /> <EllipsisVertical class="scale-75" />
+2 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import '../../app.css'; import '../../app.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte'; import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
@@ -10,12 +11,11 @@
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
<div class="flex h-screen min-h-screen flex-col"> <div class="flex h-screen min-h-screen flex-col">
<!-- Header --> <!-- Header -->
<div class="flex items-center gap-4 pt-4 pl-6"> <div class="flex items-center gap-4 pt-4 pl-6">
<p class="text-3xl">📚</p> <p class="text-3xl">📚</p>
<a href="/" class="text-2xl font-semibold">chitai</a> <a href={resolve('/')} class="text-2xl font-semibold">chitai</a>
<ThemeToggle class="mr-4 ml-auto" /> <ThemeToggle class="mr-4 ml-auto" />
</div> </div>