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