Files
Fredrik JohanssonandClaude Sonnet 5 e5c242418f
Docker / build-and-push (push) Successful in 4m53s
Add Gitea Actions workflow to build and push the Docker image on push to main
Copied from latent/.gitea/workflows/docker.yml unchanged — it derives the
image name/registry host from repo context, so it works for postcard as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 19:50:41 +02:00

3.9 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

  1. Instant — composite, then download directly, entirely client-side. Optionally generate a share link (explicit action, not automatic).
  2. 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). CI builds and pushes the image on push to main via .gitea/workflows/docker.yml (needs a repo secret TKNTKN — a Gitea PAT with packages:write scope).

Config

All via env vars:

  • TTL_HOURS — link retention (default 72h; shorter than latent'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-DD is expected to return { git?, spotify?, note? } fragments for that date. .env here points it at https://goonk.se/api/day-summary, goonk's live endpoint (confirmed returning { git, spotify? } — no note field observed yet). A response body containing an error key (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 public nominatim.openstreetmap.org; server/src/geocode.js throttles 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 read DateTimeOriginal and 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 — see fonts/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 capture is 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.