Files
RecipesCam/docker
3dtours 39f80088c7 web: a folder is chosen by picking one — the backup row names it and opens the picker, and restore always asks which backup
RESTORE was dead on a machine that had never written a backup: the button was
`disabled` while no folder was remembered, and nothing in the handler could ever
pick one, so the reader who had carried a copy over on a stick had no way to
point at it. The button now stands, and the folder it restores from is whatever
the reader picks in the dialog that opens — which is the whole of the choice
there is, because a backup is a folder of rows and tiles. The folder on the other
disk, the one made before the edit: the folder is the version.

BACK UP asked for a folder only the first time one was ever chosen. After that
it wrote into it in silence, and after a restart — when the browser takes the
permission back, since a permission outlives the tab only while the tab does —
it wrote nothing and said so, which is a backup that quietly stops happening.
It now picks whenever it cannot write, and the picker is the only thing that can
hand a remembered folder its permission back; a permission asked for with no
picker behind it is one the browser may refuse. The run that follows a scan is
untouched: it is guarded by the permission it would be asking for.

And the folder is a control, not a caption. The line that names it — with the
frames in it and when they were written — is what tells one backup from another,
and pressing it is how a folder is picked, a name given, a permission handed
back. It keeps the hint's own look through five lines of CSS, so the row still
reads as a caption and not as a third button.

What the line says is now read out of the folder rather than out of this
browser's memory of it. `pickBackupFolder` reads the catalogue file inside the
folder just picked and takes its count and its date as the row's own; a folder
with no catalogue of its own has none, and the row says so instead of showing
the numbers of the folder next door. That is also what the confirm names before
a restore — the frames and the time in the folder just picked, which is the
thing about to be restored. `restoreNow` already read that file; this reads its
header first.

Checked on the running bundle with the picker stubbed, since the dialog is one a
probe cannot press: with nothing remembered, RESTORE now opens the picker where
it did nothing at all, the row names the folder the picker returned, and a
folder without a catalogue of its own is refused with a line saying so rather
than restored as an empty catalogue. BACK UP opens it too, from the same fresh
state. The line computes to a button with no border, no background, the page's
own font and the hint's own 12px dim grey — a caption that happens to be
pressable. The wall, the strip, the grid, the deep link and the phone's recipes
all pass their checks.
2026-10-01 17:12:29 +07:00
..
…

RecipesCam web — self-contained stack

A Docker-hosted web build of RecipesCam. Everything it needs is in this folder: move it to another machine, run two commands, and the app is up. It does not need the React Native project around it.

cp .env.example .env
docker compose up -d --build
# → http://localhost:8090

What runs where

Service Image Role
frontend nginx:1.27-alpine (built by frontend/Dockerfile) Static SPA + /api/ reverse proxy
api node:22-slim (built by backend/Dockerfile) Accounts + saved recipes, SQLite on ./data

frontend resolves api through Docker's embedded DNS and proxies /api/* to it — that is why the API container is named api and why it is not published on the host. Only ${WEB_PORT:-8090} is exposed.

Photos never leave the browser. The CanvasKit render pipeline (grade, frame, watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes as JSON.

Layout

docker-compose.yml      the stack
.env.example            WEB_PORT
data/                   SQLite (created on first run, gitignored)
backend/                Fastify + better-sqlite3 API, own Dockerfile
frontend/               Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf
  shared/               vendored copies of the app's types + utils (see below)
  src/engine/           skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline)
                        + session.ts (localStorage/IndexedDB studio persistence)

Vendored files

frontend/shared/{types/index.ts,utils/*.ts} are byte-identical copies of src/types/index.ts and ten src/utils/*.ts files from the React Native project (@shopify/react-native-skia is aliased to src/engine/skiaShim.ts in vite.config.ts + tsconfig.json, so those files compile unchanged):

cinemaShader colorUtils defaultRecipes exifWrite frameUtils jpegDpi paramDefs recipeShare skiaImage toneShader.

The landing page needs no CDN: frontend/public/assets/fonts/*.woff2 are the seven self-hosted faces behind the three font groups the Themes menu offers (Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro / Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google Fonts, vietnamese + latin + latin-ext subsets), and frontend/public/assets/samples/s*.jpg are the six placeholder negatives the film strip, preset tester and QR card show (swap them for real graded stills whenever we have them). Its one foreign request is the QR image from api.qrserver.com, which degrades to an empty slot offline.

When the app changes one of them, copy it back in — the renderer is only "parity" for as long as these stay in sync:

cd <repo>/docker/frontend/shared/utils
cp <repo>/src/utils/<name>.ts .

Operations

docker compose logs -f api        # API log
docker compose restart api        # after backend/src changes (rebuild: --build)
docker compose down               # stop; ./data survives

Backup is the ./data folder — that is the whole database.

Checks

curl -s http://localhost:8090/api/health          # {"ok":true}
curl -sI http://localhost:8090/                   # 200, index.html

Then open the UI, drop a photo in, and confirm the preview shows the picture and EXPORT downloads a JPEG that opens. The preview going solid black while the export still reports a plausible size is the one failure mode worth knowing: it means the CanvasKit GPU surfaces lost their shared GrDirectContext (see frontend/src/engine/skiaShim.ts), and with no GPU the raster fallback renders the same pipeline correctly, just slower.