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