Phone photo -> composited postcard (canvas templates, stamp corner, EXIF-aware date/location, self-hosted handwriting font) with a small server for share links, day-context caption drafting off goonk's day-summary API, and Nominatim reverse geocoding. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3.8 KiB
postcard
Turn a phone photo into a sendable postcard. Upload a photo, it gets composited onto a vintage postcard template (border, stamp corner, handwriting-style caption) entirely in the browser, then either download it right away or upload it for a shareable link.
The caption is auto-drafted by assembling real fragments of that day
(git activity, what was playing, a note title) via server/src/caption.js
— never a free-text model call — and stays editable. With no day-context
source configured it falls back to a small set of generic postcard-voice
lines.
Structurally this is a copy of latent's upload → transform
→ deliver shape and server (db.js/ids.js/cleanup.js/ratelimit.js/
routes.js), with the transform swapped for postcard compositing. See
PROPOSAL.md for the original design writeup.
Two finishing paths off one editor
- Instant — composite, then download directly, entirely client-side. Optionally generate a share link (explicit action, not automatic).
- Send — uploads the composited image to server storage, returns a
link immediately (no delayed-reveal gate — that's
latent's gimmick, not this one's).
Running locally
npm install --prefix server
npm start --prefix server
Then open http://localhost:3098. server's start/dev scripts load
../.env automatically via Node's --env-file-if-exists — copy
.env.example to .env at the repo root and adjust. DATA_DIR defaults
to ./server/data (gitignored) for the SQLite db and image blobs.
Deploying
docker compose up using the provided docker-compose.yml (copy
.env.example to .env and adjust — IMAGE/HOST_PORT are only read by
compose, the rest are passed through to the container).
Config
All via env vars:
TTL_HOURS— link retention (default 72h; shorter thanlatent's, since a postcard link is meant to be opened once, not browsed later).MAX_UPLOAD_BYTES,RATE_LIMIT_PER_HOUR— basic abuse limits; no accounts or auth on any endpoint, the link itself is the credential.DAY_SUMMARY_URL— optional, no code-level default (set it in.env). If set,GET {url}?date=YYYY-MM-DDis expected to return{ git?, spotify?, note? }fragments for that date..envhere points it athttps://goonk.se/api/day-summary, goonk's live endpoint (confirmed returning{ git, spotify? }— nonotefield observed yet). A response body containing anerrorkey (goonk's shape for a missing/malformed date) is treated the same as no summary. Left unset, captions fall back to generic lines — no invented personal data.NOMINATIM_URL— reverse-geocoding endpoint for the stamp corner's location line (only queried when a photo has EXIF GPS). Defaults to the publicnominatim.openstreetmap.org;server/src/geocode.jsthrottles to 1 req/sec and caches by rounded coordinate per that API's usage policy. No API key needed.
Notable implementation details
- EXIF is parsed client-side with no dependency (
exif.js) — just enough to readDateTimeOriginaland GPS lat/lon out of a JPEG. Parse failures/missing tags fall back to upload date / no location stamp, never invented data. - The caption's handwriting font is self-hosted (
fonts/caveat-latin.woff2, Caveat, SIL OFL — seefonts/OFL.txt), not loaded from fonts.googleapis.com at runtime. - The upload card offers separate "take a photo" (camera capture) and
"choose from library" buttons rather than relying on the OS's file
picker to offer both — some mobile browsers skip the chooser and go
straight to the camera when
captureis set on a single input.
Non-goals
No accounts, no "your postcards" history, no delayed-reveal mechanic, no free-text LLM-generated captions, no collaborative/multi-recipient cards.