books: immersive reader route, PDF resume fix, scalable placeholder covers, read-only books mount in prod deploy, README

This commit is contained in:
2026-10-24 01:18:17 +00:00
parent 9c46e281c2
commit 9c8f20a618
5 changed files with 268 additions and 211 deletions

View File

@@ -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/<id>`
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
edit status, rating and progress. `/trackers/books/<id>/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/<id>` 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/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
- `BOOKS_ROOT/covers/<books.works.cover_path>` 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

View File

@@ -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/<id> (no static path).
# Layout: $books_host_dir/library/<files.path> and $books_host_dir/covers/<works.cover_path>.
# 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 <<YAML
services:
app:
environment:
BOOKS_ROOT: /books
volumes:
- type: bind
source: $books_host_dir
target: /books
read_only: true
YAML
compose_files+=(-f "$directory/compose.books.yml")
else
echo "No $books_host_dir/library on this host; book files will not be readable" >&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

View File

@@ -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 {
@container (max-width: 90px) {
.g-author {
display: none;
}
.cover-lg .g-title {
font-size: 1.5rem;
}
.cover-lg .g-author {
font-size: 0.85rem;
}
.is-placeholder {
box-shadow: none;

View File

@@ -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;

View File

@@ -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<string | null>(null);
@@ -184,6 +186,7 @@
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
Dialogs/sheets/popovers are portalled to <body> and still render above both.
-->
{#if !immersive}
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
<a
@@ -256,7 +259,10 @@
>{project.subtitle}</span
></span
>
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
{#if project.external}<ExternalLink
size={14}
class="ml-auto shrink-0"
/>{/if}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/each}
@@ -337,8 +343,10 @@
aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button
>
{#if data.user}
<Button href={resolve('account')} variant="outline" onclick={() => (menuOpen = false)}
>account</Button
<Button
href={resolve('account')}
variant="outline"
onclick={() => (menuOpen = false)}>account</Button
>
<form method="POST" action="/logout">
<Button type="submit" variant="outline">sign out</Button>
@@ -358,7 +366,9 @@
</Sheet.Root>
</div>
</header>
{/if}
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
{#if !immersive}
<footer
class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'}
@@ -393,6 +403,7 @@
</nav>
</div>
</footer>
{/if}
</div>
<style>