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
| Path | What |
| --- | --- |
| -------------------------- | ---------------------------------------------------------------------------------------- |
| `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. |
| `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. |
| `frontend/src/lib/vendor/` | Vendored `foliate-js` (the EPUB engine), copied by `frontend/scripts/vendor-foliate.sh`. |
| `frontend/static/pdfjs/` | Vendored pdf.js viewer, used by the PDF reader in an iframe. |
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. |
| `docs/screenshots/` | Images used by `README.md`. |
| `shell.nix` | Root dev shell; composes the two sub-shells. |
+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
conversion and doubles the chance of an external lookup matching.
### Any authenticated user can delete any library or book
`backend/src/chitai/database/models/library.py`, `backend/src/chitai/database/models/user.py`
There is no authorization tier. `Library` has no owner column, and `User` carries only
`email` and `password` — no role, no `is_active`. So every authenticated account can create
and delete libraries, and delete books along with their files on disk. Per-user scoping
exists only for reading progress and bookshelves, which `provide_book_service` restricts
correctly.
For a single-household deployment that may well be acceptable. The point is that it is
emergent rather than chosen. The cheapest meaningful step is an `is_admin` flag gating
library deletion and `delete_books` — a model change plus a migration.
### Basic auth answers a malformed header with a 500
`backend/src/chitai/middleware/basic_auth.py` — line 22
```python
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":")
```
Nothing guards the parse. A `Bearer` token raises `IndexError`, non-base64 raises
`binascii.Error`, and a credential with no colon raises `ValueError` — as does a password
that *contains* one, since there is no `maxsplit=1`. Every case surfaces as a 500 on an
unauthenticated endpoint. All of them should be 401.
### An unknown KOSync API key returns 404
`backend/src/chitai/middleware/kosync_auth.py` — line 32
`KosyncDeviceService.get_by_api_key` uses `get_one`, which raises `NotFoundError`, but the
middleware catches only `PermissionDeniedException`. The global handler in
`exceptions/handlers.py` then renders it as a 404, so a device presenting a bad key is told
the route does not exist rather than that it is unauthorized. The same file still carries a
leftover `print(exc)`.
Worth doing at the same time: `KosyncDeviceService._generate_api_key` uses
`secrets.token_hex(8)`. 64 bits is thin for a long-lived bearer credential where 32 bytes
is the convention.
### The multi-book download cannot be driven from a test
`backend/src/chitai/services/book.py``BookService.get_files`
`/books/download` is the only handler returning a Litestar `Stream`, and it cannot be
exercised through `AsyncTestClient`. The request itself succeeds, then fixture teardown
hangs: the test transport never sends the `http.disconnect` that the streaming response
waits on, so the app's lifespan shutdown never completes. Coverage therefore sits at the
service level, on `get_files` directly.
Unresolved whether the endpoint also stalls behind a real ASGI server, where that
disconnect does arrive. Worth one manual check against `litestar run` before relying on it.
### The production image runs the development server
`backend/Dockerfile` — the final `CMD`
```
CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8000"]
```
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian
directly, with a worker count.
### Nothing gates formatting, linting or types
`ruff format --check src/` reports 27 of 61 files unformatted, and `ruff check src/` finds
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and
`== True` comparisons. `ruff check --fix` clears 51 automatically.
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F`
rules run, and no type checker is configured at all despite `# type: ignore` comments in
the tree. Individually these are trivial; collectively they say nothing runs on commit.
## Frontend
### Scripted EPUBs run against the app origin
**This is a regression from the foliate-js migration, not a pre-existing gap.**
The old epub.js reader never passed `allowScriptedContent`. epub.js defaults it to
`false`, which sets `iframe.sandbox = "allow-same-origin"` — no `allow-scripts` — so
script inside a book never ran. The vendored foliate-js sets, unconditionally:
```js
// paginator.js — and the same in fixed-layout.js
// `allow-scripts` is needed for events because of WebKit bug
this.#iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
```
`allow-same-origin` together with `allow-scripts` is the combination that makes the
sandbox attribute do nothing. Sections are served as same-origin `blob:` URLs, so script
in a book can reach `/api/*` with the session cookie attached. foliate's README says as
much and tells you to use a CSP instead; we have not added one.
This is not theoretical. Audiobookshelf shipped the same combination and got
**CVE-2024-35236** — scripted EPUB plus an unrestricted upload gave remote code
execution; fixed in 2.10.0 by making scripted content a per-library opt-in, off by
default. Kavita (CVE-2024-39307) and Jellyfin (fixed 10.9.8) are variations on it.
Write-up: <https://gebir.ge/blog/every-trick-in-the-book/>.
**An app-wide CSP is the wrong shape.** `kit.csp` with `script-src: ['self']` also blocks
`mode-watcher`'s inline `setInitialMode`, which sets the dark class before first paint —
SvelteKit only nonces the bootstrap script it injects itself, so every page load would
flash the light theme. Pinning a hash of a third-party inline script breaks silently on
upgrade.
**Grimmory solves it properly**, and it runs foliate-js too. Rather than handing foliate
the whole file, it serves each EPUB entry from its own endpoint and puts the strict
policy on that response:
```java
// EpubReaderController.java
response.setHeader("Content-Security-Policy", "script-src 'none'");
```
The app shell keeps its own, more permissive policy. That works because each section is
then a real same-origin document with its own header, rather than a `blob:` — and a
`blob:` inherits the CSP of the document that created it, which is exactly why a header
on `/api/books/download/…` would achieve nothing today.
Two ways forward:
1. **Cheap.** Patch the vendored `sandbox` attribute to drop `allow-scripts`, restoring
what epub.js gave us. Cost is the WebKit bug the upstream comment cites: events inside
the iframe get swallowed, which would likely break touch/swipe paging and possibly the
in-iframe keyboard handling in `foliate-view.svelte`. Needs testing before trusting.
2. **Right.** Follow grimmory: serve individual EPUB entries from the backend with
`script-src 'none'` on each response, and drive foliate through its loader hooks
instead of a whole-file blob. This is **net-new capability on both sides**, not a
rewiring of something that exists — see below.
Option 2 also fixes the memory cost below, which is why it is worth more than it looks.
#### What option 2 actually involves
Today the browser fetches the whole `.epub` from `download/{book_id}/{file_id}`, which
returns a Litestar `File` and knows nothing about the archive's contents. foliate then
opens the zip **in the browser** (`makeZipLoader` in `view.js`) and turns every chapter,
image and stylesheet into a `blob:` URL via `Loader.createURL` in `epub.js`. A `blob:`
carries no headers of its own — it inherits the CSP of the document that created it —
which is why there is nowhere to attach a policy except the app shell.
foliate's parser never touches the zip directly. `EPUB` is constructed with a loader:
```js
// view.js — makeZipLoader is one implementation; makeDirectoryLoader below is another
return { entries, loadText, loadBlob, getSize };
```
`name` is the **zip entry path**, because `makeZipLoader` keys its map on
`entry.filename` — so `OEBPS/Text/chapter01.xhtml`, `OEBPS/Images/cover.jpg`. The parser
resolves hrefs from the OPF manifest into those names and asks the loader for them,
without caring where the bytes come from. A third implementation that fetches over HTTP
is the same shape.
**Backend.** An endpoint taking a path inside the archive, e.g.
`GET books/{book_id}/files/{file_id}/entry/{path:path}`, returning a `Stream` over
`zipfile.ZipFile.open(name)` so an entry never lands in memory whole, with the content
type from the manifest and `Content-Security-Policy: script-src 'none'` on the response.
Two things to get right:
- **`path` is caller-supplied.** Resolve it against the archive's `namelist()` and reject
anything absent, rather than trusting the string — `../` traversal is the hazard.
- **`getSize` is synchronous** in foliate's loader contract, and it feeds `SectionProgress`,
which produces the reading percentage. So the endpoint needs a companion that returns
entry names and sizes up front — one extra call at open — because sizes cannot be
discovered per request.
Opening the zip per request costs a central-directory read each time. Probably fine for
chapter-sized reads, worth measuring rather than assuming.
Note the backend already reads inside EPUBs — `metadata_extractor.py` uses ebooklib at
ingest for title, authors, identifiers and the cover. What is missing is serving an
arbitrary entry by path, not the ability to open the archive.
**Frontend.** `foliate-view.svelte` stops calling `view.open(file)` and builds an `EPUB`
around a loader backed by that endpoint. The whole-file fetch in `epub-reader.svelte`
goes away with it.
### The proxy buffers whole files and drops range headers
Two separate problems that both live in `frontend/src/routes/api/[...path]/+server.ts`.
**Buffering.** Litestar already streams: `ASGIFileResponse` reads in 1 MB chunks
(`response/file.py`), so the backend never holds a file whole. The proxy then undoes it
with `await response.arrayBuffer()`, which does not resolve until the last byte arrives —
so the whole file sits in the node process, per concurrent reader, and the browser gets
nothing until it completes. Passing `response.body` straight through restores the stream
and is a small change.
**Range.** The proxy forwards only `Content-Type`, `Content-Disposition` and
`Content-Length`. It never sends the client's `Range` upstream, and would drop
`Accept-Ranges` and `Content-Range` coming back — a 206 without `Content-Range` is
broken. So range support cannot work until the proxy is fixed, whatever the backend does.
**Litestar has no range support of its own.** In 2.21.1 the only mention of 206 in the
whole package is the `HTTP_206_PARTIAL_CONTENT` constant; there is no `Accept-Ranges` or
`Content-Range` handling anywhere. This has to be written.
#### What pdf.js actually needs
It decides from the **initial 200 response**, not from anything on a 206.
`validateRangeRequestCapabilities` in `frontend/static/pdfjs/build/pdf.mjs`:
```js
if (responseHeaders.get("Accept-Ranges") !== "bytes") {
return returnValues; // allowRangeRequests stays false
}
```
It also needs a parseable `Content-Length`, `Content-Encoding: identity`, and a length
greater than twice `rangeChunkSize`. Miss any of those and it downloads the whole file
however good the range support is.
So the single highest-value header is **`Accept-Ranges: bytes` on the ordinary 200** —
that is what makes pdf.js switch to fetching progressively at all.
#### Approach
Put it on the existing `get_file` handler in `controllers/book.py`, which already resolves
`book_id`/`file_id` through the service with library scoping and auth:
- No `Range``Stream` the file with `Accept-Ranges: bytes` and `Content-Length`.
- `Range` present → parse, seek, `Stream` with 206 and `Content-Range`.
- Proxy: forward `Range` up; pass `response.body` through; forward `Accept-Ranges`,
`Content-Range` and the status back.
There are `RangeRequestMiddleware` snippets circulating for Litestar that wrap
`create_static_files_router`. They are the wrong shape here — book files are served by an
authenticated handler resolving database ids, not by a directory mapping, and using one
would mean exposing disk paths as URLs and re-solving ownership checks that already
exist. The common version also only sets `Accept-Ranges` on the 206, so it would not
switch pdf.js over, and it derives its path with `str.lstrip(prefix)`, which strips a
character set rather than a prefix — `/static/castle.pdf` becomes `le.pdf`. Worth reading
its `parse_range_header` for the parsing rules and writing the rest fresh.
### Remove the epub.js locations-cache purge
`frontend/src/lib/reader/legacy-cache.ts``purgeLegacyLocationCache`
The epub.js reader cached generated locations in `localStorage` under
`${bookId}-locations`, a few hundred KB of JSON per long book. foliate computes
progress from section byte sizes at open time, so nothing writes those keys any
more, but existing browsers still hold them — and a reader near the 510 MB
origin quota would make the new reader-settings write throw `QuotaExceededError`.
The reader clears them once per browser, behind a `chitai:locations-purged` flag.
Delete the module, its call in `epub-reader.svelte` and the flag once deployments
have had a release or two to run it — after roughly 2026-12.
### Cover dimensions are unknown until load
`book-cover.svelte` renders covers at a fixed height with natural width so nothing is
+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.
**Stack:** SvelteKit 2 with `adapter-node` · Svelte 5 (runes) · Tailwind v4 · Zod v4 ·
`epubjs` · `mode-watcher` (dark mode) · `svelte-sonner` (toasts) · pnpm.
vendored `foliate-js` (EPUB) · vendored `pdf.js` (PDF) · `mode-watcher` (dark mode) ·
`svelte-sonner` (toasts) · pnpm.
Two experimental flags are on in `svelte.config.js` and the codebase depends on both:
`kit.experimental.remoteFunctions` and `compilerOptions.experimental.async` (`await` in components).
Tailwind v4 has **no config file** — the theme, oklch colour tokens and `@custom-variant dark` all
live in `src/app.css`.
Tailwind v4 has **no config file** — the theme, colour tokens (hex, not oklch) and
`@custom-variant dark` all live in `src/app.css`. `dark` is the _only_ custom variant defined, so
generated components that assume others — shadcn's slider ships `data-horizontal:` / `data-vertical:`
classes — silently produce no styles. Use the `data-[orientation=…]` form instead.
## Talking to the backend
@@ -65,8 +68,12 @@ Svelte context with a module-level `Symbol` key and a `setXState` / `getXState`
```ts
const LIBRARY_KEY = Symbol('LIBRARY');
export function setLibraryState(libraries: Library[]) { return setContext(LIBRARY_KEY, new LibraryState(libraries)); }
export function getLibraryState() { return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY); }
export function setLibraryState(libraries: Library[]) {
return setContext(LIBRARY_KEY, new LibraryState(libraries));
}
export function getLibraryState() {
return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY);
}
```
Follow that pattern rather than introducing stores. `library.svelte.ts` is the reference — including
@@ -79,10 +86,56 @@ its optimistic-delete-with-rollback and toast handling. `bookCollection` / `book
`@ieedan/shadcn-svelte-extras` (`jsrepo.json`). Treat as generated: add components with the CLIs
rather than hand-writing them, and prefer wrapping over editing.
- App components live in `forms/`, `layout/`, `view/` (browser, grid/list/table, filters, sort) and
`reader/` (epub reader + chapter sidebar).
`reader/` (see [The readers](#the-readers)).
- `cn()` from `$lib/utils` merges Tailwind classes; the `WithElementRef` / `WithoutChild` helpers
there are the shadcn prop-typing conventions.
## The readers
**PDF** is the pdf.js viewer vendored under `static/pdfjs/`, pointed at by an iframe. Untouched by
the EPUB work; leave it alone unless the task is about PDFs.
**EPUB** is built on `foliate-js`, copied verbatim into `src/lib/vendor/foliate-js/` by
`scripts/vendor-foliate.sh` (pinned commit; see `src/lib/vendor/foliate-js/README.chitai.md`).
Upstream has no npm release and recommends a submodule; this repo has none and already vendors
pdf.js the same way, so it is copied instead. Only the import closure reachable from `view.js` is
vendored, and **`pdf.js` in that directory is our stub, not upstream's** — the real one imports a
bare `@pdfjs/pdf.min.mjs` that Rollup resolves at build time even though the path never runs.
Layout:
| Path | What |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| `lib/vendor/foliate-js/` | The engine. Do not edit — `vendor-foliate.sh` overwrites it. |
| `lib/reader/foliate.ts` | Lazy loader for the custom elements. The only thing that imports `$foliate`. |
| `lib/reader/settings.ts` · `stylesheet.ts` | Defaults/bounds, and the CSS injected into the book. |
| `lib/reader/progress.ts` | Debounced progress writer with a `sendBeacon` flush. |
| `lib/state/reader-settings.svelte.ts` | Settings state, persisted to `localStorage`. |
| `components/reader/foliate-view.svelte` | Wraps `<foliate-view>`; owns the imperative lifecycle. |
| `components/reader/epub-reader.svelte` | The shell: chrome, TOC, errors, progress. |
Things that will bite:
- **`$foliate` is a Vite-only alias.** It is deliberately absent from `kit.alias` and tsconfig
`paths` so TypeScript cannot resolve it and falls back to the ambient declaration in
`lib/reader/foliate-js.d.ts`; `src/lib/vendor` is also in tsconfig `exclude`. Without both,
`checkJs` walks ~11k lines of untyped JS. The declaration file must **not** be named `foliate.d.ts`
— beside `foliate.ts`, TypeScript takes it for that file's emitted declaration and drops it.
- **Never import the vendored code at module scope.** `view.js` calls `customElements.define` and
subclasses `HTMLElement` on import, so it must stay behind `loadFoliate()` inside `onMount`. SSR is
otherwise on for the reader route.
- **Sections render in iframes, which swallow key events.** Keyboard handlers are bound per section
document on the `load` event, and modifier combinations are replayed onto the host window so app
shortcuts (the sidebar's ctrl+B) still work while reading.
- **Renderer settings split two ways.** Flow, gap, margins, column count and line width are
_attributes_ set with `setAttribute` (there is no JS property API, no `margin` shorthand and no
`spread` — a spread is `max-column-count: 2`). Typography is CSS passed to `renderer.setStyles`,
which takes a `[before, after]` pair: the first is prepended to the section head so the book
overrides it, the second appended so it wins. User settings belong in the second, with
`!important`, or the book's own CSS beats them.
- **Progress needs no locations pre-pass.** `relocate` carries both a CFI and an overall `fraction`,
which map straight onto `epub_cfi` and `percentage`.
## Routing
Route groups carry the layout structure:
@@ -95,21 +148,26 @@ Route groups carry the layout structure:
## Conventions
Prettier (`.prettierrc`): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done.
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done
but take a baseline first, because neither is clean (see below).
`src/lib/vendor/` is excluded from Prettier, ESLint and svelte-check. Don't reformat vendored code.
## Known rough edges
Observed in the current tree — don't mistake these for intentional patterns to copy:
- `src/lib/schema/openapi/schema.d.ts` is **stale** — it predates the `BookProgress` rework and has
no `BookProgressRead`, so `book.progress` types as `{}`. This is the source of most of the ~104
errors `pnpm check` reports on a clean tree; regenerating it should clear them. Get a baseline
before assuming an error is yours.
no `BookProgressRead`, so `book.progress` types as `{}`. It is the source of most of the errors
`pnpm check` reports; regenerating it should clear them. **Baseline as of 2026-08-11: 37 errors,
1 warning, 11 files.** Get your own baseline before assuming an error is yours.
- `pnpm lint` does not pass either — 131 pre-existing ESLint errors, 35 of them
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4.
- `src/app.d.ts``App.Locals["user"]` is typed from `lucide-svelte`'s `User` *icon* component
import of that type is commented out at line 4. It also buffers whole responses with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed.
- `src/app.d.ts``App.Locals["user"]` is typed from `lucide-svelte`'s `User` _icon_ component
rather than the `User` interface in `$lib/server/auth`.
- Uncommitted work in progress (as of 2026-08-10): library icons, spanning
`components/ui/icon-picker/`, the newly vendored `components/ui/popover/`,
`forms/library-create-form.svelte`, `layout/library-switcher.svelte` and `schema/library.ts`.
Prefer not to refactor those files mid-flight.
- No CSP, which foliate's README asks for because EPUBs can carry scripts. See `TODO.md` for why it
is not enabled yet.
+6
View File
@@ -29,6 +29,12 @@ export default defineConfig(
'no-undef': 'off'
}
},
{
// Generated shadcn components take href as a prop and cannot resolve it —
// that is the caller's job. Editing them here would be lost on regeneration.
files: ['src/lib/components/ui/**'],
rules: { 'svelte/no-navigation-without-resolve': 'off' }
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
-1
View File
@@ -48,7 +48,6 @@
},
"dependencies": {
"construct-style-sheets-polyfill": "^3.1.0",
"epubjs": "^0.3.93",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.8",
"zod": "^4.3.6"
-218
View File
@@ -11,9 +11,6 @@ importers:
construct-style-sheets-polyfill:
specifier: ^3.1.0
version: 3.1.0
epubjs:
specifier: ^0.3.93
version: 0.3.93
mode-watcher:
specifier: ^1.1.0
version: 1.1.0(svelte@5.53.7)
@@ -899,10 +896,6 @@ packages:
'@types/json-schema@7.0.15':
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':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@@ -1008,11 +1001,6 @@ packages:
'@vue/shared@3.5.29':
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:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1176,12 +1164,6 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
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:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1197,10 +1179,6 @@ packages:
csstype@3.2.3:
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:
resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
engines: {node: '>=18'}
@@ -1262,20 +1240,6 @@ packages:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
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:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -1334,10 +1298,6 @@ packages:
esm-env@1.2.2:
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:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1367,12 +1327,6 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
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:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1478,9 +1432,6 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1493,9 +1444,6 @@ packages:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -1532,9 +1480,6 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1575,9 +1520,6 @@ packages:
resolution: {integrity: sha512-LoCmV2n7rVry/gD4aMd9No7N3rB6xxxbbJedtdku8Ic7+JYbJRly6GWw+tO28/iuDxzAI0fpcgEoO0JyW+AUPg==}
hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1592,12 +1534,6 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
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:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'}
@@ -1676,9 +1612,6 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
localforage@1.10.0:
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@@ -1689,9 +1622,6 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
@@ -1707,9 +1637,6 @@ packages:
resolution: {integrity: sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==}
engines: {node: ^20.17.0 || >=22.9.0}
marks-pane@1.0.9:
resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==}
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
@@ -1785,9 +1712,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-machine-id@1.1.12:
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
@@ -1838,9 +1762,6 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1867,9 +1788,6 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-webpack@0.0.3:
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1995,9 +1913,6 @@ packages:
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
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:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -2006,9 +1921,6 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -2066,9 +1978,6 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -2080,9 +1989,6 @@ packages:
set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2129,9 +2035,6 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -2271,9 +2174,6 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
typescript-eslint@8.56.1:
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3030,10 +2930,6 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/localforage@0.0.34':
dependencies:
localforage: 1.10.0
'@types/node@22.19.15':
dependencies:
undici-types: 6.21.0
@@ -3193,8 +3089,6 @@ snapshots:
'@vue/shared@3.5.29': {}
'@xmldom/xmldom@0.7.13': {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -3356,10 +3250,6 @@ snapshots:
cookie@0.6.0: {}
core-js@3.48.0: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -3374,11 +3264,6 @@ snapshots:
csstype@3.2.3: {}
d@1.0.2:
dependencies:
es5-ext: 0.10.64
type: 2.7.3
debounce-fn@6.0.0:
dependencies:
mimic-function: 5.0.1
@@ -3420,36 +3305,6 @@ snapshots:
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:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@@ -3559,13 +3414,6 @@ snapshots:
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:
dependencies:
acorn: 8.16.0
@@ -3594,15 +3442,6 @@ snapshots:
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-json-stable-stringify@2.1.0: {}
@@ -3693,8 +3532,6 @@ snapshots:
ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -3704,8 +3541,6 @@ snapshots:
index-to-position@1.2.0: {}
inherits@2.0.4: {}
inline-style-parser@0.2.7: {}
ip-address@10.1.0: {}
@@ -3734,8 +3569,6 @@ snapshots:
is-unicode-supported@2.1.0: {}
isarray@1.0.0: {}
isexe@2.0.0: {}
jiti@2.6.1: {}
@@ -3805,13 +3638,6 @@ snapshots:
- ws
- 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:
dependencies:
json-buffer: 3.0.1
@@ -3825,14 +3651,6 @@ snapshots:
prelude-ls: 1.2.1
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:
optional: true
@@ -3884,10 +3702,6 @@ snapshots:
lilconfig@2.1.0: {}
localforage@1.10.0:
dependencies:
lie: 3.1.1
locate-character@3.0.0: {}
locate-path@6.0.0:
@@ -3896,8 +3710,6 @@ snapshots:
lodash.merge@4.6.2: {}
lodash@4.17.23: {}
lru-cache@11.2.6: {}
lz-string@1.5.0: {}
@@ -3922,8 +3734,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
marks-pane@1.0.9: {}
mimic-function@5.0.1: {}
minimatch@10.2.4:
@@ -3990,8 +3800,6 @@ snapshots:
negotiator@1.0.0: {}
next-tick@1.1.0: {}
node-machine-id@1.1.12: {}
obug@2.1.1: {}
@@ -4054,8 +3862,6 @@ snapshots:
package-manager-detector@1.6.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -4081,8 +3887,6 @@ snapshots:
lru-cache: 11.2.6
minipass: 7.1.3
path-webpack@0.0.3: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -4140,22 +3944,10 @@ snapshots:
proc-log@6.1.0: {}
process-nextick-args@2.0.1: {}
punycode@2.3.1: {}
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: {}
require-from-string@2.0.2: {}
@@ -4231,8 +4023,6 @@ snapshots:
dependencies:
mri: 1.2.0
safe-buffer@5.1.2: {}
safer-buffer@2.1.2:
optional: true
@@ -4240,8 +4030,6 @@ snapshots:
set-cookie-parser@3.0.1: {}
setimmediate@1.0.5: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -4291,10 +4079,6 @@ snapshots:
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -4449,8 +4233,6 @@ snapshots:
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):
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)
@@ -0,0 +1,223 @@
<script lang="ts">
import { FileUp, Folder, FileText } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button/index.js';
import type { FileRejectedReason } from '$lib/components/ui/file-drop-zone';
import { cn } from '$lib/utils';
let {
onUpload,
onFileRejected,
accept,
maxFileSize,
disabled = false,
class: className
}: {
onUpload: (files: File[]) => Promise<void> | void;
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
/** Comma separated extensions and/or MIME types, as the `accept` attribute takes. */
accept?: string;
/** Bytes. */
maxFileSize?: number;
disabled?: boolean;
class?: string;
} = $props();
/**
* Two inputs rather than one.
*
* `webkitdirectory` is not a filter — it switches the picker into folder mode,
* so a single input can offer files or folders but never both. The drop target
* has no such constraint and stays one area.
*/
let fileInput = $state<HTMLInputElement>();
let folderInput = $state<HTMLInputElement>();
let dragging = $state(false);
let busy = $state(false);
const active = $derived(!disabled && !busy);
function accepts(file: File): FileRejectedReason | undefined {
if (maxFileSize !== undefined && file.size > maxFileSize) return 'Maximum file size exceeded';
if (!accept) return undefined;
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
const ok = accept
.split(',')
.map((pattern) => pattern.trim().toLowerCase())
.some((pattern) => {
// Match on the pattern, not the file's type. Testing `type` here is
// what makes MOBI fail: browsers report no MIME type for it, so a
// ".mobi" rule never gets compared against the filename.
if (pattern.startsWith('.')) return name.endsWith(pattern);
if (pattern.endsWith('/*')) return type.startsWith(pattern.slice(0, -1));
return type === pattern;
});
return ok ? undefined : 'File type not allowed';
}
/** readEntries hands back at most 100 at a time and signals the end with an empty batch. */
function readAll(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
const entries: FileSystemEntry[] = [];
const next = () =>
reader.readEntries((batch) => {
if (batch.length === 0) return resolve(entries);
entries.push(...batch);
next();
}, reject);
next();
});
}
/**
* Flattens a dropped entry into files, naming each one with its path inside the
* dropped folder so it matches what the folder picker puts in
* `webkitRelativePath` — which is what the upload form reads to keep structure.
*/
async function walk(entry: FileSystemEntry, prefix = ''): Promise<File[]> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject)
);
return [new File([file], `${prefix}${file.name}`, { type: file.type })];
}
if (entry.isDirectory) {
const entries = await readAll((entry as FileSystemDirectoryEntry).createReader());
const nested = await Promise.all(entries.map((e) => walk(e, `${prefix}${entry.name}/`)));
return nested.flat();
}
return [];
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
dragging = false;
if (!active) return;
// Read the entries synchronously: DataTransfer is emptied as soon as this
// handler yields, so awaiting first loses everything that was dropped.
const entries = Array.from(event.dataTransfer?.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.webkitGetAsEntry())
.filter((entry): entry is FileSystemEntry => entry !== null);
// Older engines expose no entries; fall back to the flat list, which cannot
// contain folders anyway.
if (entries.length === 0) {
await submit(Array.from(event.dataTransfer?.files ?? []));
return;
}
busy = true;
try {
const nested = await Promise.all(entries.map((entry) => walk(entry)));
await submit(nested.flat());
} finally {
busy = false;
}
}
async function handleChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const chosen = Array.from(input.files ?? []);
// Reset first so picking the same file twice still fires a change event.
input.value = '';
await submit(chosen);
}
async function submit(candidates: File[]) {
const accepted: File[] = [];
for (const file of candidates) {
const reason = accepts(file);
if (reason) onFileRejected?.({ file, reason });
else accepted.push(file);
}
if (accepted.length > 0) await onUpload(accepted);
}
</script>
<div
role="group"
aria-label="Add books"
aria-disabled={!active}
ondragover={(e) => {
e.preventDefault();
if (active) dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={handleDrop}
class={cn(
'flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-border bg-accent/20 p-6 text-center transition-colors',
dragging && 'border-primary bg-accent/50',
!active && 'opacity-50',
className
)}
>
<div
class="flex size-12 place-items-center justify-center rounded-full border border-dashed border-border text-muted-foreground"
>
<FileUp class="size-5" />
</div>
<div class="flex flex-col gap-0.5">
<span class="font-medium">
{busy ? 'Reading folder…' : 'Drop books here'}
</span>
<span class="text-sm text-muted-foreground">A folder keeps its structure</span>
</div>
<span class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">or</span>
<div class="flex flex-wrap justify-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={!active}
onclick={() => fileInput?.click()}
>
<FileText class="size-4" />
Choose files
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!active}
onclick={() => folderInput?.click()}
>
<Folder class="size-4" />
Choose folder
</Button>
</div>
<input
bind:this={fileInput}
type="file"
multiple
{accept}
class="hidden"
onchange={handleChange}
/>
<!-- webkitdirectory is why this needs to be a second input: it turns the
picker into a folder chooser rather than filtering what it accepts. -->
<input
bind:this={folderInput}
type="file"
multiple
webkitdirectory
class="hidden"
onchange={handleChange}
/>
</div>
@@ -1,165 +1,231 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { X } from '@lucide/svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import {
displaySize,
FileDropZone,
type FileDropZoneProps
} from '$lib/components/ui/file-drop-zone';
import { X } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch/index';
import { displaySize, type FileRejectedReason } from '$lib/components/ui/file-drop-zone';
import BookDropZone from './book-drop-zone.svelte';
import { tick } from 'svelte';
import { Spinner } from '$lib/components/ui/spinner/index';
import { getLibraryState } from '$lib/state/library.svelte';
import { uploadBooks } from '$lib/api';
import type { Book, PaginatedResponse } from '$lib/schema';
import { goto } from '$app/navigation';
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
let { open = $bindable() }: { open?: boolean } = $props();
let libraryState = getLibraryState();
$effect(() => {
uploadBooks.fields.library_id.set(libraryState.activeLibrary!.id);
});
let files = $derived(uploadBooks.fields.files.value() ?? []);
const libraryState = getLibraryState();
const queue = getUploadQueueState();
let libraryId = $state<number>(untrack(() => libraryState.activeLibrary!.id));
let files = $state<File[]>([]);
let autoUploadOnDrop = $state(true);
let navigateOnUpload = $state(true);
let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
// Rename files to use webkitRelativePath so directory structure is preserved through form submission
const renamedFiles = uploadedFiles.map(f =>
new File([f], f.webkitRelativePath || f.name, { type: f.type })
const totalSize = $derived(files.reduce((sum, file) => sum + file.size, 0));
/**
* Collected rather than raised one at a time. A folder of a few hundred books
* carries covers and notes alongside them, and a toast per rejected file
* buries the screen.
*/
let rejected = $state<{ name: string; reason: FileRejectedReason }[]>([]);
let showRejected = $state(false);
// Start each visit clean — a list left over from last time reads as though it
// applies to what was just chosen.
$effect(() => {
if (open) {
untrack(() => {
files = [];
rejected = [];
showRejected = false;
libraryId = libraryState.activeLibrary!.id;
});
}
});
const onUpload = async (uploadedFiles: File[]) => {
// Rename to the path relative to the chosen folder, which is what decides
// how the API groups files into books. Dropped folders already arrive named
// this way, since the drop zone builds the path while walking them.
const named = uploadedFiles.map(
(file) => new File([file], file.webkitRelativePath || file.name, { type: file.type })
);
uploadBooks.fields.files.set([...Array.from(files), ...renamedFiles]);
if (autoUploadOnDrop && files.length > 0) {
await tick();
formEl?.requestSubmit();
// Same relative path twice is the same file — dropping a folder a second
// time should not queue everything again.
const seen = new Set(files.map((file) => file.name));
files = [...files, ...named.filter((file) => !seen.has(file.name))];
if (autoUploadOnDrop) await startUpload();
};
const onFileRejected = ({ 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;
let libraryId = books.items[0].library_id;
libraryState.setActive(libraryId);
if (books.items.length === 1) {
goto(`/book/${books.items[0].id}`);
} else {
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`);
queue.enqueue(target, queued, ({ created, firstBook }) => {
if (created === 0) return;
const library = libraryState.libraries.find((lib) => lib.id === target);
if (library) library.total = (library.total ?? 0) + created;
// Only for a single book. Jumping somewhere after a bulk import would
// land minutes after the reader moved on.
if (navigateOnUpload && created === 1 && firstBook) {
libraryState.setActive(target);
void goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(firstBook.id) }));
}
});
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
{#if uploadBooks.pending}
<div class="flex flex-col items-center gap-4">
<span class="text-lg font-semibold"
>Uploading {uploadBooks.fields.files.value().length} files...</span
>
<Spinner class="scale-150" />
</div>
{:else}
<!--
Wider than the default lg: a folder's worth of rows needs the room.
overflow-hidden and the min-w-0 on the body below are what keep a long
filename inside the dialog. Dialog.Content is a grid, and grid and flex
items default to min-width:auto — they refuse to shrink below their
content's intrinsic width, so one long name widened the body and pushed it
straight through the dialog's edge regardless of any truncate further down.
-->
<Dialog.Content class="overflow-hidden sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>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>
<form
{...uploadBooks.enhance(async ({ submit, form }) => {
try {
await submit();
// Check if there are any validation issues
const issues = uploadBooks.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
// Update library book count
const count = uploadBooks.result.total
libraryState.libraries.find(lib => uploadBooks.fields.library_id.value() == lib.id.toString())!.total += count
// Reset the files field
uploadBooks.fields.files.set([]);
toast.success('Books successfully uploaded!');
if (navigateOnUpload) {
navigateToBooks(uploadBooks.result);
}
} catch (error) {
console.error('Failed to upload book: ', error);
toast.error('Failed to upload books');
}
})}
bind:this={formEl}
enctype="multipart/form-data"
class="flex w-full flex-col gap-2 p-4"
>
<!-- Library select field -->
<Field.Label for="library_id">Select Library</Field.Label>
<NativeSelect.Root {...uploadBooks.fields.library_id.as('select')} class="w-36">
{#each libraryState.libraries as library}
<NativeSelect.Option value={library.id}>
{library.name}
</NativeSelect.Option>
<div class="flex w-full min-w-0 flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Field.Label for="library_id">Library</Field.Label>
<NativeSelect.Root id="library_id" bind:value={libraryId} class="w-48">
{#each libraryState.libraries as library (library.id)}
<NativeSelect.Option value={library.id}>{library.name}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
</div>
<FileDropZone
<BookDropZone
{onUpload}
{onFileRejected}
directory={true}
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
sublabel="Only PDF, EPUB, and MOBI files supported"
/>
<input class="hidden" {...uploadBooks.fields.files.as('file multiple')} />
<div class="flex max-h-[300px] flex-col gap-2 overflow-y-auto">
{#each files as file, idx}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{file.name}</span>
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
{#if files.length > 0}
<div class="flex items-baseline justify-between border-b pb-1 text-sm">
<span><strong class="tabular-nums">{files.length}</strong> ready to upload</span>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{displaySize(totalSize)}
</span>
</div>
{/if}
<div class="flex max-h-[300px] min-w-0 flex-col gap-2 overflow-y-auto">
{#each files as file, idx (file.name)}
{@const location = splitPath(file.name)}
<div class="flex min-w-0 items-center justify-between gap-2">
<!-- flex-1 as well as min-w-0: without a constrained width there is
nothing for truncate to act against and the row pushes the dialog wide -->
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm" title={file.name}>{location.name}</span>
<span class="flex min-w-0 items-baseline gap-2 text-xs text-muted-foreground">
{#if location.dir}
<span class="truncate font-mono" title={location.dir}>{location.dir}</span>
{/if}
<span class="shrink-0 tabular-nums">{displaySize(file.size)}</span>
</span>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
uploadBooks.fields.files.set([
...Array.from(files).slice(0, idx),
...Array.from(files).slice(idx + 1)
]);
}}
class="shrink-0"
onclick={() => (files = files.filter((_, i) => i !== idx))}
>
<X />
<span class="sr-only">Remove {file.name}</span>
</Button>
</div>
{/each}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit">Upload</Button>
{#if rejected.length > 0}
<div class="rounded-md border border-star/50 bg-star/10 p-2 text-sm">
<div class="flex items-center justify-between gap-2">
<span>
{rejected.length}
{rejected.length === 1 ? 'file was' : 'files were'} skipped
</span>
<Button variant="ghost" size="sm" onclick={() => (showRejected = !showRejected)}>
{showRejected ? 'Hide' : 'Show'}
</Button>
</div>
<div class="flex items-center gap-2">
<Switch bind:checked={navigateOnUpload} />
<Field.Label for="navigate-to-book">Navigate to book on upload</Field.Label>
</div>
</div>
</form>
{#if showRejected}
<ul class="mt-2 flex max-h-32 min-w-0 flex-col gap-1 overflow-y-auto">
{#each rejected as entry (entry.name)}
<li class="flex min-w-0 justify-between gap-2 text-xs text-muted-foreground">
<span class="min-w-0 flex-1 truncate" title={entry.name}>
{splitPath(entry.name).name}
</span>
<span class="shrink-0">{entry.reason}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<div class="flex flex-col gap-2 border-t pt-3">
<div class="flex items-center gap-2">
<Switch id="auto-upload-on-drop" bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop" class="font-normal">
Start as soon as books are added
</Field.Label>
<Button class="ml-auto w-fit" disabled={files.length === 0} onclick={startUpload}>
Upload
</Button>
</div>
<div class="flex items-center gap-2">
<Switch id="navigate-to-book" bind:checked={navigateOnUpload} />
<Field.Label for="navigate-to-book" class="font-normal">
Open the book when a single one is added
</Field.Label>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -1,18 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { type Book } from '$lib/schema';
import EditCover from './edit-cover.svelte';
import EditFiles from './edit-files.svelte';
import EditMetadata from './edit-metadata.svelte';
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
/**
* The metadata form lives in a child, but Save belongs in the dialog footer —
* a submit button reaches it by id rather than the footer having to duplicate
* the submit logic.
*/
const METADATA_FORM_ID = 'edit-book-metadata';
</script>
<!--
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
changes underneath it as different books are edited. bookToEdit is never
cleared, so {#if book} stays true and the 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
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.
@@ -20,29 +27,41 @@
<Dialog.Root bind:open>
{#if book}
{#key book.id}
<Dialog.Content class="sm:max-w-xl">
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
<Tabs.Trigger value="cover">Cover</Tabs.Trigger>
<Tabs.Trigger value="files">Files</Tabs.Trigger>
</Tabs.List>
<Dialog.Content
class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
>
<Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
<Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
>
<Dialog.Description class="truncate text-xs">
{book.authors.map((author) => author.name).join(', ') || 'Unknown author'}
</Dialog.Description>
</Dialog.Header>
<!-- Metadata form -->
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1">
<EditMetadata {book} {open} />
</Tabs.Content>
<!-- Cover form -->
<Tabs.Content value="cover">
<EditCover {book} {open} />
</Tabs.Content>
<!-- Add files form -->
<Tabs.Content value="files">
<!-- Rail and form scroll independently so the footer never moves -->
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_1fr]">
<aside
class="flex min-w-0 flex-col gap-5 overflow-y-auto border-b bg-sidebar p-4 md:border-r md:border-b-0"
>
<EditCover {book} />
<EditFiles {book} />
</Tabs.Content>
</Tabs.Root>
</aside>
<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>
{/key}
{/if}
@@ -1,34 +1,23 @@
<script lang="ts">
import {
FileDropZone,
type FileDropZoneProps,
displaySize
} from '$lib/components/ui/file-drop-zone/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Switch } from '$lib/components/ui/switch/index';
import BookImage from '$lib/components/view/book-image.svelte';
import { tick, untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { updateBookCover } from '$lib/api';
import type { Book } from '$lib/schema';
import { tick, untrack } from 'svelte';
import { X } from '@lucide/svelte';
import BookImage from '$lib/components/view/book-image.svelte';
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone/index.js';
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
let { book }: { book: Book } = $props();
let formEl = $state<HTMLFormElement>();
// Seeded once per mount — see the key in edit-book.svelte
let coverImagePreview = $state(untrack(() => `/api/${book.cover_image}`));
let autoUploadOnDrop = $state(true);
let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
updateBookCover.fields.file.set(uploadedFiles[0]);
updateCoverPreview();
if (autoUploadOnDrop && updateBookCover.fields.file.value()) {
await tick();
formEl?.requestSubmit();
}
};
function updateCoverPreview() {
@@ -36,14 +25,14 @@
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
coverImagePreview = reader.result;
coverImagePreview = reader.result as string;
};
reader.readAsDataURL(file);
}
}
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
toast.error(`${file.name} failed to upload!`, { description: reason });
toast.error(`${file.name} was not used`, { description: reason });
};
$effect(() => {
@@ -54,65 +43,40 @@
});
</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
bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => {
try {
await submit();
form.reset();
open = false;
toast.success('Updated book cover!');
// Deliberately does not close the dialog. The cover is one panel of a
// larger form now, and closing here would throw away metadata edits
// the reader has not saved yet.
toast.success('Cover updated');
} catch (error) {
console.error('Failed to update book cover: ', error);
toast.error('Failed to update cover.');
toast.error('Failed to update the cover');
}
})}
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.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
{onUpload}
{onFileRejected}
accept=".jpeg,.jpg,.png,.webp,image/*"
label="Only JPEG, PNG, and WEBP images supported"
label="Replace cover"
sublabel="JPEG, PNG or WEBP"
maxFiles={1}
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
/>
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
<div class="flex flex-col gap-2">
{#if updateBookCover.fields.file.value()}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{updateBookCover.fields.file.value().name}</span>
<span class="text-xs text-muted-foreground"
>{displaySize(updateBookCover.fields.file.value().size)}</span
>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
updateBookCover.fields.file.set(undefined);
coverImagePreview = `/api/${book.cover_image}`;
}}
>
<X />
</Button>
</div>
{/if}
</div>
<div class="flex flex-row items-center space-x-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit" disabled={!updateBookCover.fields.file.value()}>Upload</Button>
</div>
</div>
</form>
</section>
@@ -1,98 +1,196 @@
<script lang="ts">
import { uploadBookFiles } from '$lib/api';
import {
displaySize,
FileDropZone,
type FileDropZoneProps
} from '$lib/components/ui/file-drop-zone';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field/index.js';
import { Switch } from '$lib/components/ui/switch/index';
import { X } from '@lucide/svelte';
import type { Book } from '$lib/schema';
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { Download, Trash2 } from '@lucide/svelte';
import { uploadBookFiles } from '$lib/api';
import type { Book, BookFile } from '$lib/schema';
import { formatFileSize, getFileType } from '$lib/utils';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone';
import { Spinner } from '$lib/components/ui/spinner/index';
let { book }: { book: Book } = $props();
let files = $derived(uploadBookFiles.fields.files.value() ?? []);
const bookOps = getBookOperationsState();
let autoUploadOnDrop = $state(true);
/**
* The book's files, owned locally.
*
* `book` is a snapshot handed down from bookOperations rather than a live
* query, so invalidating after a delete does not reach it. Keeping the list
* here lets the rail reflect an add or a remove straight away. Seeded once per
* mount — edit-book.svelte keys the dialog on book.id.
*/
let files = $state<BookFile[]>(untrack(() => [...book.files]));
// The field's value is a sparse-ish list until the form settles, so narrow it
// before rendering rather than asserting at each use.
let pending = $derived(
(uploadBookFiles.fields.files.value() ?? []).filter((file): file is File => Boolean(file))
);
let fileToDelete = $state<BookFile>();
let deleteFromDisk = $state(true);
let confirmOpen = $state(false);
let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]);
if (autoUploadOnDrop && files.length > 0) {
const onUpload: FileDropZoneProps['onUpload'] = async (uploaded) => {
uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
await tick();
formEl?.requestSubmit();
}
};
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
toast.error(`${file.name} failed to upload!`, { description: reason });
toast.error(`${file.name} was not added`, { description: reason });
};
function confirmDelete(file: BookFile) {
fileToDelete = file;
confirmOpen = true;
}
async function removeFile() {
if (!fileToDelete) return;
const target = fileToDelete;
confirmOpen = false;
// Drop it from the list first: the request invalidates the books query, but
// this dialog holds its own copy of the book and would not see that.
files = files.filter((file) => file.id !== target.id);
await bookOps.deleteBookFiles(book.id, [target.id], deleteFromDisk);
fileToDelete = undefined;
}
</script>
<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
{...uploadBookFiles.enhance(async ({ submit, form }) => {
bind:this={formEl}
{...uploadBookFiles.enhance(async ({ submit }) => {
try {
await submit();
// Check if there are any validation issues
const issues = uploadBookFiles.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
if (issues && issues.length > 0) 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([]);
toast.success('Files successfully added!');
toast.success('Files added');
} catch (error) {
console.error('Failed to upload files: ', error);
toast.error('Failed to upload files');
console.error('Failed to add files: ', error);
toast.error('Failed to add files');
}
})}
bind:this={formEl}
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 class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
<FileDropZone
{onUpload}
{onFileRejected}
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')} />
<div class="flex flex-col gap-2">
{#each files as file, idx}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{file.name}</span>
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
uploadBookFiles.fields.files.set([
...Array.from(files).slice(0, idx),
...Array.from(files).slice(idx + 1)
]);
}}
>
<X />
</Button>
</div>
{/each}
</form>
</section>
<AlertDialog.Root bind:open={confirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove {fileToDelete?.filename}?</AlertDialog.Title>
<AlertDialog.Description>
This cannot be undone. The other files on this book are not affected.
</AlertDialog.Description>
</AlertDialog.Header>
<!-- The API takes these as separate outcomes: drop the record, or drop the
record and the file on disk. Leaving it implicit would mean deleting
someone's only copy without saying so. -->
<div class="flex items-center gap-2">
<Checkbox id="delete-from-disk" bind:checked={deleteFromDisk} />
<Field.Label for="delete-from-disk" class="font-normal">
Also delete the file from the filesystem
</Field.Label>
</div>
<div class="flex flex-row items-center space-x-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit">Upload</Button>
</div>
</form>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={removeFile}>
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,18 +1,23 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { Minus, Plus } from '@lucide/svelte';
import { updateBookMetadata } from '$lib/api';
import type { Book } from '$lib/schema';
import { untrack } from 'svelte';
import { Minus, Plus } from '@lucide/svelte';
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
book,
open = $bindable(),
/** Lets the dialog footer own the submit button via the `form` attribute. */
formId
}: { book: Book; open: boolean; formId: string } = $props();
// Seeded once per mount; edit-book.svelte keys this form on book.id so a
// different book gets a fresh form rather than the previous book's values.
@@ -22,7 +27,6 @@
let identifierValues = $state(untrack(() => Object.values(book.identifiers)));
function handleAddIdentifier() {
// Add empty strings to both arrays
identifierKeys = [...identifierKeys, ''];
identifierValues = [...identifierValues, ''];
}
@@ -88,9 +92,17 @@
});
</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
{...updateBookMetadata.enhance(async ({ submit, form }) => {
id={formId}
{...updateBookMetadata.enhance(async ({ submit }) => {
try {
await submit();
@@ -101,28 +113,19 @@
}
open = false;
book = book;
toast.success('Updated book metadata!');
} catch (error) {
console.error('Error occurred updating book metadata: ', error);
toast.error('Failed to update book metadata.');
}
})}
class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
>
<Card.Content>
<Field.Set>
<Field.Group class="flex flex-col">
<!-- Book ID field -->
<Field.Field class="hidden">
<Field.Label for="book_id">Book ID</Field.Label>
<Input {...updateBookMetadata.fields.book_id.as('text')} />
{#each updateBookMetadata.fields.book_id.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
<!-- Title field -->
<Field.Field>
{@render groupHeading('Identity')}
<Field.Field class="col-span-full">
<Field.Label for="title">Title</Field.Label>
<Input {...updateBookMetadata.fields.title.as('text')} />
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
@@ -130,7 +133,6 @@
{/each}
</Field.Field>
<!-- Subtitle field -->
<Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label>
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
@@ -139,8 +141,14 @@
{/each}
</Field.Field>
<div class="grid grid-cols-[3fr_1fr] gap-2">
<!-- Series 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>
<Field.Field>
<Field.Label for="series">Series</Field.Label>
<Input {...updateBookMetadata.fields.series.as('text')} />
@@ -149,18 +157,27 @@
{/each}
</Field.Field>
<!-- Series position field -->
<div class="grid grid-cols-2 gap-2">
<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')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</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>
<!-- Authors field -->
<Field.Field>
{@render groupHeading('People and subjects')}
<Field.Field class="col-span-full">
<Field.Label for="authors">Authors</Field.Label>
<TagsInput
bind:value={authors}
@@ -176,8 +193,7 @@
{/each}
</Field.Field>
<!-- Tags field -->
<Field.Field>
<Field.Field class="col-span-full">
<Field.Label for="tags">Tags</Field.Label>
<TagsInput
bind:value={tags}
@@ -193,39 +209,8 @@
{/each}
</Field.Field>
<!-- Description field -->
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
{@render groupHeading('Publication')}
<!-- 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.Label for="publisher">Publisher</Field.Label>
<Input {...updateBookMetadata.fields.publisher.as('text')} />
@@ -234,18 +219,15 @@
{/each}
</Field.Field>
<!-- Published date field -->
<div class="grid grid-cols-2 gap-2">
<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')} />
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
<!-- Pages field -->
<Field.Field>
<Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} />
@@ -253,32 +235,44 @@
<Field.Error>{issue.message}</Field.Error>
{/each}
</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>
</Field.Group>
</Field.Set>
</Card.Content>
<!-- Submit button -->
<Card.Footer class="flex-col gap-2 pt-6">
<Button type="submit" class="w-full">Save</Button>
</Card.Footer>
<Field.Field class="col-span-full">
<Field.Label for="identifiers">Identifiers</Field.Label>
<div class="flex flex-col gap-2">
{#each identifierKeys as _, idx}
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
<Input bind:value={identifierValues[idx]} placeholder="Value" />
<Button
type="button"
variant="outline"
size="icon"
title="Remove identifier"
onclick={() => handleRemoveIdentifier(idx)}
>
<Minus />
<span class="sr-only">Remove identifier</span>
</Button>
</div>
{/each}
<Button type="button" variant="outline" class="w-fit" onclick={handleAddIdentifier}>
<Plus />
Add identifier
</Button>
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
</div>
</Field.Field>
{@render groupHeading('Description')}
<Field.Field class="col-span-full">
<Field.Label for="description" class="sr-only">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</form>
</Card.Root>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Separator } from '$lib/components/ui/separator/index.js';
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
@@ -27,7 +28,7 @@
// directly in the markup keeps it static.
const header = $derived({
title: 'chitai',
url: `/library/${libraryState.activeLibrary!.id}`
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
});
</script>
@@ -40,6 +41,8 @@
-->
<Sidebar.MenuButton class="mt-1 -ml-1.5">
{#snippet child({ props })}
<!-- header.url is built with resolve(); the rule cannot trace the variable. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={header.url} {...props}>
<ChitaiMark class="mr-3 size-7!" />
<span class="font-serif text-xl tracking-tight">{header.title}</span>
@@ -1,8 +1,9 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js";
import { Badge } from '$lib/components/ui/badge/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
@@ -22,10 +23,25 @@
// rebuilt on every navigation the icons would remount, flashing and shifting
// layout. Only the url and active state need to be reactive, so they are
// computed per-item in the markup instead.
//
// Active state is matched on route id rather than pathname. resolve() returns
// an absolute path on the client but a relative one during SSR, so comparing
// it to page.url.pathname would be false on the server and true after
// hydration — the highlight would flash in.
const items = [
{ title: 'Home', icon: House, path: (id?: number) => `/library/${id}` },
{ title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` },
{ title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] }
{
title: 'Home',
icon: House,
routeId: '/(root)/(library)/library/[libraryId]',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
},
{
title: 'Library',
icon: LibraryBig,
routeId: '/(root)/(library)/library/[libraryId]/view',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
},
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
];
</script>
@@ -41,7 +57,7 @@
<Sidebar.Menu>
{#each items as item (item.title)}
{@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}
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
{#snippet child({ props })}
@@ -78,17 +94,18 @@
<Sidebar.MenuSubButton>
{#snippet child({ props })}
<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}
>
<Badge
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}
</Badge>
<span>{shelf.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
@@ -101,8 +118,10 @@
</Collapsible.Root>
{:else}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive} tooltipContent={item.title} class="h-10">
<Sidebar.MenuButton {isActive} tooltipContent={item.title} class="h-10">
{#snippet child({ props })}
<!-- item.path() calls resolve(); the rule cannot trace the variable. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={url} {...props}>
{#if item.icon}
<item.icon class="scale-125" />
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
@@ -31,13 +32,16 @@
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Avatar.Fallback
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
{initials}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
@@ -53,13 +57,16 @@
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Avatar.Fallback
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
{initials}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
>
</div>
</div>
</DropdownMenu.Label>
@@ -67,11 +74,11 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onSelect={() => goto('/settings/account')}>
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/account'))}>
<SettingsIcon class="size-4" />
Settings
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goto('/settings/appearance')}>
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/appearance'))}>
<PaletteIcon class="size-4" />
Appearance
</DropdownMenu.Item>
@@ -82,7 +89,7 @@
<DropdownMenu.Item
onSelect={async () => {
await logout();
await goto('/login');
await goto(resolve('/login'));
}}
>
<LogOutIcon class="size-4" />
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Command from '$lib/components/ui/command/index';
import * as Kbd from '$lib/components/ui/kbd/index.js';
import * as InputGroup from '$lib/components/ui/input-group/index.js';
@@ -9,6 +10,7 @@
import { getLibraryState } from '$lib/state/library.svelte';
import type { PaginatedResponse, Book } from '$lib/schema';
import BookImage from '../view/book-image.svelte';
import GeneratedCover from '../view/generated-cover.svelte';
import { goto } from '$app/navigation';
const libraryState = getLibraryState();
@@ -97,16 +99,22 @@
<Command.Item
value={String(book.id)}
onSelect={() => {
goto(`/book/${book.id}`);
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) }));
open = false;
}}
class="cursor-pointer"
>
<div class="hover:bg-base-200 flex gap-4 p-3">
{#if book.cover_image}
<BookImage
src="/api/{book.cover_image}"
class="w-24 rounded object-cover shadow-lg"
/>
{:else}
<div class="aspect-9/12 w-24 shrink-0 overflow-hidden rounded shadow-lg">
<GeneratedCover {book} />
</div>
{/if}
<div class="flex-top flex flex-col">
<span class="font-serif text-lg">{book.title}</span>
<span class=" text-md">{book.subtitle}</span>
@@ -115,7 +123,9 @@
by
{#each book.authors as author}
<a
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
})}?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/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">
import { resolve } from '$app/paths';
import { onMount, untrack } from 'svelte';
import {
@@ -25,14 +26,14 @@
import ReaderToc from './reader-toc.svelte';
let {
bookUrl,
fileUrl,
bookId,
filename,
title = '',
initialProgress = 0,
initialEpubLoc = null
}: {
bookUrl: string;
fileUrl: string;
bookId: string | number;
filename: string;
title?: string;
@@ -78,7 +79,7 @@
file = undefined;
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.`);
file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
@@ -135,7 +136,7 @@
</Tooltip.Provider>
<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"
{title}
>
@@ -178,7 +179,10 @@
<RotateCcw class="size-4" />
Try again
</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
</a>
</div>
@@ -104,7 +104,46 @@
doc.addEventListener('keydown', onKeydown);
}
/**
* Replays a shortcut pressed inside the book on the host window.
*
* Key events do not cross document boundaries, so a shortcut pressed while
* the book has focus never reaches the app's handlers — which are bound on
* the window, as the sidebar's ctrl+B is — and the browser's own default runs
* instead. That is why ctrl+B opened bookmarks in the book but toggled the
* chapter sidebar everywhere else.
*
* The original is only cancelled if an app handler actually claimed the
* replay, so combinations the app does not use — ctrl+C above all — keep
* their normal browser behaviour.
*/
function forwardShortcut(event: KeyboardEvent) {
const source = (event.target as Node | null)?.ownerDocument;
// isTrusted rules out the replay itself, which would otherwise recurse.
if (!event.isTrusted || !source || source === document) return;
const claimed = !window.dispatchEvent(
new KeyboardEvent('keydown', {
key: event.key,
code: event.code,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
bubbles: true,
cancelable: true
})
);
if (claimed) event.preventDefault();
}
function onKeydown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) {
forwardShortcut(event);
return;
}
switch (event.key) {
case 'ArrowLeft':
case 'PageUp':
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Book } from '$lib/schema';
import GeneratedCover from './generated-cover.svelte';
let {
book,
@@ -10,12 +11,6 @@
let failed = $state(false);
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>
<!--
@@ -65,20 +60,10 @@
{:else}
<!-- No cover on record, or the file is missing. Draw one. -->
<span
class="flex h-full flex-col justify-between overflow-hidden rounded-sm p-3 text-left shadow-lg"
style="width: {Math.round(height * 0.66)}px; background: linear-gradient(152deg, hsl({hue}
30% 34%), hsl({hue} 38% 18%));"
class="h-full overflow-hidden rounded-sm shadow-lg"
style="width: {Math.round(height * 0.66)}px;"
>
<span
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}
<GeneratedCover {book} />
</span>
{/if}
</span>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import BookCover from './book-cover.svelte';
import BookActionsMenu from './book-actions-menu.svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index';
@@ -120,7 +121,7 @@
? '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} />
</a>
@@ -128,7 +129,7 @@
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<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"
>
{book.title}
@@ -166,7 +167,9 @@
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
<a
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
>
{/each}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Table from '$lib/components/ui/table/index';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
@@ -52,7 +53,9 @@
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. */
function missing(book: Book) {
@@ -208,19 +211,26 @@
/>
</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>
{#each visible as column (column.key)}
{#if column.key === 'title'}
<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>
{:else if column.key === 'authors'}
<Table.Cell class="max-w-[180px] truncate">
{#each book.authors as author (author.id)}
<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
> &thinsp;
{/each}
@@ -248,7 +258,7 @@
{formatFileSize(totalSize(book))}
</Table.Cell>
{: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)}
</Table.Cell>
{:else if column.key === 'progress'}
@@ -263,7 +273,7 @@
style="width: {Math.round(book.progress.percentage * 100)}%;"
></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)}%
</span>
</span>
@@ -1,11 +1,13 @@
<script lang="ts">
import { resolve } from '$app/paths';
import BookImage from './book-image.svelte';
import GeneratedCover from './generated-cover.svelte';
import { Progress } from '$lib/components/ui/progress/index';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import type { Book } from '$lib/schema';
let { book, class: className = '', ...rest }: { book: Book, class?: string} = $props();
let { book, class: className = '', ...rest }: { book: Book; class?: string } = $props();
const selectionState = getBookSelectionState();
const libraryState = getLibraryState();
@@ -24,12 +26,19 @@
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
<!-- Book Cover -->
<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
? 'ring-2 ring-star'
: ''}"
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
src="/api/{book.cover_image}"
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
@@ -37,6 +46,16 @@
? 'brightness-50'
: ''}"
/>
{:else}
<div
class="h-full w-full transition-all duration-200 group-hover:brightness-50 {selected ||
darkened
? 'brightness-50'
: ''}"
>
<GeneratedCover {book} />
</div>
{/if}
<!-- Inside the anchor, which already clips to rounded-sm, so the bar
follows the cover's corners instead of floating below it -->
@@ -51,7 +70,9 @@
</a>
<!-- 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
>
@@ -59,7 +80,9 @@
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
{#each book.authors as author}
<a
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary?.id ?? '')
})}?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/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);
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);
}
@@ -137,7 +140,8 @@ export class BookCollectionState {
url.searchParams.set('orderBy', this.orderBy);
url.searchParams.set('sortOrder', this.sortOrder);
// pushState(url.toString(), {})
// Same as setView: the current URL with sort params rewritten, not a route.
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.toString());
this.loadNewBooks();
@@ -285,9 +289,7 @@ export class BookCollectionState {
return wanted.every(([key, values]) => {
const current = this.filters[key] ?? [];
return (
current.length === values.length && values.every((value) => current.includes(value))
);
return 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 { goto, invalidate } from '$app/navigation';
import { page } from '$app/state';
@@ -31,7 +32,9 @@ export class LibraryState {
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
if (browser) {
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
await goto(`/library/${libraryId}/view`, { invalidate: ['app:libraries'] });
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), {
invalidate: ['app:libraries']
});
}
}
@@ -0,0 +1,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">
import { resolve } from '$app/paths';
import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
import { CollapsibleText } from '$lib/components/ui/collapsible-text/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
@@ -17,12 +18,7 @@
PlusIcon,
Trash2
} from '@lucide/svelte';
import {
describeIdentifier,
formatFileSize,
getFileType,
sortIdentifiers
} from '$lib/utils.js';
import { describeIdentifier, formatFileSize, getFileType, sortIdentifiers } from '$lib/utils.js';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
import { getLibraryState } from '$lib/state/library.svelte.js';
@@ -65,12 +61,24 @@
: 'Read'
);
// window.open is outside the lint rule's reach, but the paths need resolving
// just the same — they would break under a non-empty base path.
function openBookInReader(file: BookFile) {
const params = { bookId: String(book.id), fileId: String(file.id) };
if (getFileType(file.filename) === 'EPUB')
window.open(`/book/${book.id}/read/epub/${file.id}`, '_blank', 'noopener,noreferrer');
window.open(
resolve('/(root)/(library)/book/[bookId]/read/epub/[fileId]', params),
'_blank',
'noopener,noreferrer'
);
if (getFileType(file.filename) === 'PDF')
window.open(`/book/${book.id}/read/pdf/${file.id}`, '_blank', 'noopener,noreferrer');
window.open(
resolve('/(root)/(library)/book/[bookId]/read/pdf/[fileId]', params),
'_blank',
'noopener,noreferrer'
);
}
// On the band, the accent is the ground — invert the buttons against it.
@@ -101,7 +109,7 @@
</h1>
{#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 book.series}
@@ -115,7 +123,9 @@
By
{#each book.authors as author (author.id)}
<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
> &thinsp;
{/each}
@@ -169,7 +179,11 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
{: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" />
{readLabel}
</button>
@@ -184,7 +198,6 @@
Download
</button>
{/if}
</div>
</div>
</div>
@@ -267,10 +280,11 @@
</dt>
<dd class="m-0 font-mono break-all tabular-nums">
{#if id.href}
<!-- Always an absolute URL off-site: openlibrary, doi.org or amazon. -->
<a
href={id.href}
target="_blank"
rel="noopener noreferrer"
rel="external noopener noreferrer"
class="hover:text-primary hover:underline">{value}</a
>
{:else}
@@ -288,7 +302,14 @@
</h2>
<div class="flex flex-wrap gap-1.5">
{#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}
</div>
</div>
@@ -302,7 +323,9 @@
<div class="flex flex-wrap gap-1.5">
{#each book.lists as shelf (shelf.id)}
<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
>
{/each}
@@ -462,7 +485,10 @@
<Table.Cell class="font-mono text-xs tabular-nums">
{formatFileSize(file.size)}
</Table.Cell>
<Table.Cell><span class="font-mono text-xs">{getFileType(file.filename)}</span></Table.Cell>
<Table.Cell
><span class="font-mono text-xs">{getFileType(file.filename)}</span
></Table.Cell
>
<Table.Cell class="text-right">
<div class="flex justify-end gap-1">
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
@@ -8,11 +8,11 @@
const bookId = page.params.bookId!;
// 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>
<EpubReader
{bookUrl}
{fileUrl}
{bookId}
title={data.title}
filename={data.filename}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
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 -->
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<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"
title={data?.title}
>
@@ -76,7 +77,10 @@
<RotateCcw class="size-4" />
Try again
</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
</a>
</div>
+10
View File
@@ -2,12 +2,14 @@
import { listBookshelves } from '$lib/api';
import AppSidebar from '$lib/components/layout/app-sidebar.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 * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type { Library, PaginatedResponse } from '$lib/schema';
import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
import { setBookshelfState } from '$lib/state/bookshelf.svelte';
import { setLibraryState } from '$lib/state/library.svelte.js';
import { setUploadQueueState } from '$lib/state/upload-queue.svelte';
import { setThemeState } from '$lib/theme/theme.svelte';
import type { ThemeConfig } from '$lib/theme/presets';
@@ -30,6 +32,10 @@
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id);
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
// light/dark switch flips, rewrite them for the mode now showing.
$effect(() => {
@@ -52,3 +58,7 @@
</div>
</Sidebar.Provider>
</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">
import { page } from '$app/state';
import { resolve } from '$app/paths';
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 = [
{
title: 'Account',
url: '/settings/account'
},
{
title: 'Appearance',
url: '/settings/appearance'
},
{
title: 'Libraries',
url: '/settings/libraries'
},
{
title: 'Devices',
url: '/settings/devices'
}
];
{ title: 'Account', routeId: '/(root)/settings/account' },
{ title: 'Appearance', routeId: '/(root)/settings/appearance' },
{ title: 'Libraries', routeId: '/(root)/settings/libraries' },
{ title: 'Devices', routeId: '/(root)/settings/devices' }
] as const;
</script>
<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">
{#each items as item}
<a
href={item.url}
class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page.url.pathname.endsWith(item.url)
href={resolve(item.routeId)}
class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page
.route.id === item.routeId
? 'bg-muted'
: 'text-muted-foreground'}"
>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { logout } from '$lib/api';
import { Button } from '$lib/components/ui/button/index.js';
@@ -12,7 +13,7 @@
<Button
onclick={async () => {
await logout();
goto('/login');
goto(resolve('/login'));
}}
variant="outline"
class="w-32"
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { getLibraryState } from '$lib/state/library.svelte';
@@ -36,7 +37,10 @@
>{library.name[0]}</Table.Cell
>
<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 class="w-16 text-center">
<EllipsisVertical class="scale-75" />
+2 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import '../../app.css';
import favicon from '$lib/assets/favicon.svg';
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
@@ -10,12 +11,11 @@
<link rel="icon" href={favicon} />
</svelte:head>
<div class="flex h-screen min-h-screen flex-col">
<!-- Header -->
<div class="flex items-center gap-4 pt-4 pl-6">
<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" />
</div>