A personal reading archive. The database is the utility; the 3D bookshelf is the interface.
Books stand in a row that runs left to right across a white room. Vertical wheel or trackpad scrolling moves you sideways along it. One book is always focused at the centre — larger, more upright, forward in space — and carries a small block of type beneath it. Everything is drawn from SQLite; nothing about a book is hardcoded, and a book's colour is assigned once and stored, so it never changes on its own.
Everything runs in Docker. This machine has Compose v2 as the standalone
docker-compose binary rather than as a docker compose subcommand, so:
docker-compose up # http://localhost:3000The first build takes a few minutes while better-sqlite3 is compiled inside
the image. On first boot the database is created and seeded with twelve example
books so there is something to scroll through immediately.
docker-compose exec web npx vitest run # tests
docker-compose down # stop
docker volume rm booktracker_bookdata # start over with a fresh shelfThe data lives in the named volume bookdata, mounted at /data, and survives
rebuilds. node_modules is deliberately container-owned: the native SQLite
binding is built for the image's platform and must not be shadowed by the host's.
Copy .env.example to .env and set it:
cp .env.example .env
echo 'PORT=4500' > .env
docker-compose up # http://localhost:4500Only the host side moves — the container always listens on 3000, which the
Dockerfile's EXPOSE and the production image both assume. Without a .env it
falls back to 3000, so a fresh clone runs unchanged. .env is gitignored.
docker build --target runner -t booktracker-prod .
docker run -p 3000:3000 -v booktracker_bookdata:/data booktracker-prod| Scroll / trackpad | move along the shelf — vertical input, horizontal travel, no Shift |
| Drag | same, on touch or with a mouse held down |
| ← → | previous / next book |
| Enter, Space | edit the focused book |
| Escape | close whatever is open |
+ |
add a book |
/ |
jump to the search line |
| Enter, ↓ / ↑ (in search) | next / previous match, wrapping |
| Escape (in search) | clear the query, stay where you landed |
The underline across the top is the search. Typing sends the shelf gliding to the first match under the same damped motion as everything else — the collection is never filtered or hidden, so what you see is always the whole archive.
Terms are ANDed and matched as substrings across title, author and note, with
case and accents folded, so left darkness finds The Left Hand of Darkness and
stanislaw finds Stanisław Lem. When nothing matches, the shelf does not move
and the counter says so.
Adding a book needs only a title and an author. The colour is chosen for you from the six-value palette and written to the row; the swatches in the edit sheet are there if you want to overrule it later. Deleting asks first.
app/
page.tsx server component; reads the database for the first paint
api/books/… GET, POST, PATCH, DELETE
components/
Archive.tsx client root: collection state, focus, overlay layout
scene/Shelf.tsx canvas, static camera, lighting, fog, render window
scene/Book.tsx one book's meshes and its per-frame transform
scene/covers.ts runtime canvas textures and shared materials
scene/useShelfInput wheel, drag and keyboard folded into one motion model
ui/ metadata, form, chrome
lib/
db, books, seed SQLite: schema, CRUD, first-run data
palette the six colours and how one is chosen
dimensions per-book proportions derived from the row id
motion damping, scroll integration, the focus curve
Three decisions are worth knowing about.
Scroll sets velocity, never position. Every frame the position is critically damped toward a target, so movement is continuous, interruptible, and settles onto a book rather than snapping to it. Every book's placement is then a pure function of its distance from the focus — one curve describes the whole shelf, and no book carries state of its own.
Nothing is downloaded. Covers and spines are drawn to a 2D canvas at runtime — blind-stamped type on a transparent ground, so the case colour shows through with no seam — and uploaded as textures. There are no fonts, images or models to fetch, and editing a title or colour redraws the texture on the existing object.
Physical proportions come from the row id, not from a column. Hashing the id gives each book a stable width, height and thickness for as long as the row exists, which is as permanent as storing them while keeping the schema at the five fields the product actually needs.
The shelf mounts only a window of books around the focus, shares one material per palette colour, and prints covers only near the centre. Measured in a software rasterizer, from twelve books to a thousand:
| books | HTML payload | TTFB | canvas up | frame median | DOM nodes | heap |
|---|---|---|---|---|---|---|
| 12 | 15 KB | 442 ms | 489 ms | 33.4 ms | 47 | 73 MB |
| 100 | 28 KB | 56 ms | 102 ms | 33.4 ms | 47 | 93 MB |
| 300 | 58 KB | 54 ms | 102 ms | 33.4 ms | 47 | 93 MB |
| 600 | 104 KB | 66 ms | 120 ms | 33.4 ms | 47 | 78 MB |
| 1000 | 164 KB | 109 ms | 139 ms | 33.4 ms | 47 | 93 MB |
Frame time, DOM size and heap are flat: collection size is not what governs the frame rate. The only cost that grows is the initial HTML, which carries every row for the first paint — linear, and still only 164 KB at a thousand books. If this archive ever reached tens of thousands, that payload is the thing to page, not the scene.