Skip to content

Repository files navigation

Read

A personal reading archive. The database is the utility; the 3D bookshelf is the interface.

The shelf, focused on Dune, with the search underline across the top

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.

Running it

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:3000

The 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 shelf

The 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.

Changing the port

Copy .env.example to .env and set it:

cp .env.example .env
echo 'PORT=4500' > .env
docker-compose up          # http://localhost:4500

Only 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.

Production image

docker build --target runner -t booktracker-prod .
docker run -p 3000:3000 -v booktracker_bookdata:/data booktracker-prod

Using it

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

Finding a book

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.

How it is put together

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.

Scale

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.

About

A personal reading archive whose primary interface is a horizontal 3D bookshelf — vertical scroll drives sideways travel through the collection. SQLite is the source of truth; every cover is generated at runtime, with no external assets.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages