Backend for the TRMNL screenshot plugin. TRMNL points at /display and screenshots it onto its 800×480 e-ink panel. Friends and family upload images at /; the most recent upload is what shows on the wall.
| Route | Purpose |
|---|---|
GET / | Upload form (email required, image required, caption optional). |
POST /upload | Stores the processed image, caption, and uploader record. |
GET /image | Serves the current image as binary image/png. |
GET /display | The page TRMNL screenshots — image at 800×480 with optional caption bar. Public link to share with TRMNL. |
GET /admin | HTTP Basic auth (admin / ADMIN_PASSWORD). Lists every uploader with upload count, last upload, reminder state. |
GET /admin/test-email?to=... | Sends one reminder email to the given address. No state changes. |
GET /admin/run-cron | Manually triggers the reminder cron. Supports ?dry=1 (don't send/persist) and ?days_ahead=N (simulate future time). Returns JSON summary. |
GET /opt-out?email=... | Flips reminders to false for an email. Linked from reminder emails. |
Public URL: https://joshbeckman--b073704c405011f19b9c42b51c65c3df.web.val.run. Friendlier alias for sharing: http://www.joshbeckman.org/frametags/home-trmnl-small.
backend/
index.ts # Hono HTTP app (all routes above)
cron.ts # Daily reminder job (separate cron trigger in Val Town)
frontend/
index.html # Upload form
index.tsx # Client-side resize + upload (no dithering — TRMNL handles B&W)
Done client-side in frontend/index.tsx:
- Draw the uploaded image onto an 800×480 canvas, scaled to cover.
canvas.toDataURL("image/png")→ POST to/upload.
We don't dither to 1-bit ourselves — TRMNL converts the screenshot to B&W on its end, and pre-dithering caused weirdness. We also avoid image-rendering: pixelated on /display for the same reason. The /image endpoint decodes the stored data URL and serves binary PNG so /display references it as a real URL (TRMNL's screenshot pipeline doesn't like huge inline data: URIs).
| Key | Shape |
|---|---|
trmnl_image_data | { image: dataUrl, caption: string } — current image only. |
trmnl_uploaders | { [email]: Uploader } |
type Uploader = {
lastUpload: string; // ISO timestamp
count: number; // total uploads
reminders: boolean; // opt-in flag, default true
lastReminded?: string | null;
reminderCount?: number; // how many reminders sent in current cycle
}
Uploading resets lastReminded and reminderCount so the cycle starts over.
Runs daily. For each uploader with reminders === true and reminderCount < 4, sends a nudge if enough time has passed. The email body lives in sendReminderEmail() in cron.ts — /admin/test-email calls the same function. Backoff doubles each reminder:
| Reminder # | Wait since previous touch |
|---|---|
| 1 | 14 days (since last upload) |
| 2 | 28 days |
| 3 | 56 days |
| 4 | 112 days |
| 5+ | never — silence |
Total: at most 4 emails over ~7 months, then we leave them alone. Each email contains an opt-out link.
ADMIN_PASSWORD— required for/admin. Username is hardcoded toadmin.
vt watch # auto-sync edits to Val Town vt tail # stream logs vt browse # open in browser