From 8c65ae6e964871bc9bec6336ebad5d8e7341defa Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 24 Oct 2026 01:18:17 +0000 Subject: [PATCH] books: immersive reader route, PDF resume fix, scalable placeholder covers, read-only books mount in prod deploy, README --- README.md | 24 ++ scripts/deploy-remote.sh | 29 +- src/lib/books/BookCover.svelte | 24 +- src/lib/books/reader/PdfReader.svelte | 3 + src/routes/+layout.svelte | 399 +++++++++++++------------- 5 files changed, 268 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index abf9284..0c4787f 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,30 @@ local database and migrations, and `db/reorg/README.md` for the one-time move of old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …) upsert reference data and run on every production deploy. +## Book tracker + +`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/` +shows a book; the owner also sees files, notes, highlights and reading sessions there and can +edit status, rating and progress. `/trackers/books//read` is the owner-only reader: +EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js) +(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the +position per file, autosaves progress and sessions, and stores highlights with notes. + +Files are never public. They are streamed (with Range support) by +`/trackers/books/files/` to the owner only; everyone else gets a 404. The reader, progress +and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in +`hooks.server.ts`). + +Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`): + +- `BOOKS_ROOT/library/` for book files (written by `scripts/books-ingest.py`) +- `BOOKS_ROOT/covers/` for downloaded covers + +In production, `scripts/deploy-remote.sh` writes `/opt/personal-website/compose.books.yml`, which +bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets +`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't +be opened. + ## Money tracker `/projects/money-tracker` is restricted to administrator accounts. Its tables diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index bedd9bb..7094878 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -18,6 +18,29 @@ environment_name=production image="git.stanrunge.dev/stan/website:$sha" compose="$directory/compose.prod.yml" +compose_files=(-f "$compose") +# Book files (ebooks + covers) live on the host and are bind-mounted read-only at /books; +# the app streams them only to the owner via /trackers/books/files/ (no static path). +# Layout: $books_host_dir/library/ and $books_host_dir/covers/. +# compose.prod.yml is host-managed, so the mount goes in a generated override file. +books_host_dir=${BOOKS_HOST_DIR:-/home/stan/books} +case "$books_host_dir" in /*) ;; *) echo 'BOOKS_HOST_DIR must be absolute' >&2; exit 2 ;; esac +if [ -d "$books_host_dir/library" ]; then + sudo tee "$directory/compose.books.yml" >/dev/null <&2 +fi # Images built before the repo rename (stan/personal-website -> stan/website) keep # their old local name; retag so rollbacks to those SHAs still work. legacy_image="git.stanrunge.dev/stan/personal-website:$sha" @@ -34,7 +57,7 @@ rollback() { if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then echo "Health check failed; restoring prior image tag $previous_tag" >&2 printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null - sudo docker compose --env-file "$directory/.env" -f "$compose" up -d + sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d fi } trap rollback ERR @@ -92,9 +115,9 @@ sudo docker run --rm --network backend --env-file "$environment" "$image" \ printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null switched=1 -sudo docker compose --env-file "$directory/.env" -f "$compose" up -d +sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d -container=$(sudo docker compose --env-file "$directory/.env" -f "$compose" ps -q app) +container=$(sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" ps -q app) expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image") actual_image=$(sudo docker inspect -f '{{.Image}}' "$container") if [ "$actual_image" != "$expected_image" ]; then diff --git a/src/lib/books/BookCover.svelte b/src/lib/books/BookCover.svelte index 5d510bf..886144f 100644 --- a/src/lib/books/BookCover.svelte +++ b/src/lib/books/BookCover.svelte @@ -51,6 +51,7 @@ aspect-ratio: 2 / 3; width: 100%; overflow: hidden; + container-type: inline-size; border-radius: 0.375rem 0.625rem 0.625rem 0.375rem; background: var(--site-hover); box-shadow: @@ -99,31 +100,26 @@ font-family: Georgia, 'Times New Roman', serif; font-weight: 600; line-height: 1.15; - font-size: 1rem; + /* Scale with the cover's width so thumbnails don't break words mid-way. */ + font-size: clamp(0.55rem, 12cqi, 1.6rem); display: -webkit-box; -webkit-line-clamp: 6; line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden; - overflow-wrap: anywhere; + overflow-wrap: break-word; + hyphens: auto; } .g-author { - font-size: 0.7rem; + font-size: clamp(0.5rem, 6.5cqi, 0.85rem); letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.8; } - .cover-sm .g-title { - font-size: 0.6rem; - } - .cover-sm .g-author { - display: none; - } - .cover-lg .g-title { - font-size: 1.5rem; - } - .cover-lg .g-author { - font-size: 0.85rem; + @container (max-width: 90px) { + .g-author { + display: none; + } } .is-placeholder { box-shadow: none; diff --git a/src/lib/books/reader/PdfReader.svelte b/src/lib/books/reader/PdfReader.svelte index 88a669d..2355bdc 100644 --- a/src/lib/books/reader/PdfReader.svelte +++ b/src/lib/books/reader/PdfReader.svelte @@ -127,6 +127,9 @@ let lastScale = 0; $effect(() => { const s = scale; + // The first scale with pages loaded is the baseline; the initial jump to the saved + // position happens in onMount and must not be undone by a "keep position" here. + if (!pages.length || !width) return; if (!lastScale) { lastScale = s; return; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index bda3f04..c7d6ebc 100755 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -30,6 +30,8 @@ let { data, children } = $props(); const seo = $derived(getSeo(page.url.pathname, page.data, data.environment)); + // The book reader is a full-screen view; the site header and footer would sit on top of it. + const immersive = $derived(/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname)); let menuOpen = $state(false); // Which header dropdown is open (desktop): '/projects', '/trackers' or none. let openMenu = $state(null); @@ -184,215 +186,224 @@ Fix: lift the header (relative z-50) and isolate
so page z-indexes stay inside it. Dialogs/sheets/popovers are portalled to and still render above both. --> -
-
- - +
+ - stan runge - - - - - - - stan rungeNavigation -
+ {/if} +
+ {:else} - {#if menus[item.href]} -
- {#each menus[item.href].items as project (project.name)} - - {/each} -
- {/if} - {/each} - - - - {#if data.user} - -
- -
- {:else} - - {item.label} {/if} -
- - - -
-
{@render children()}
-
-
-
- stan runge {data.version.slice(0, 7)} - source -
- + -
+ {#if data.user} + + {:else} + + {/if} + + + + + stan rungeNavigation + + + + {#if data.user} + +
+ +
+ {:else} + + + {/if} +
+
+ + + + {/if} +
{@render children()}
+ {#if !immersive} +
+
+
+ stan runge {data.version.slice(0, 7)} + source +
+ +
+
+ {/if}