Files
photog/CLAUDE.md
T
foxandClaude Fable 5.1 ab0bd5b65a feat(manage): delete photos from /manage, prune leftover pages
/manage/delete/{slug} asks for confirmation, then removes the image,
its sidecar and everything generated from them. Builds now drop day
pages of days without photos and pager pages past the last one, so a
delete or a date change leaves nothing behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 05:53:12 +00:00

10 KiB
Raw Blame History

photogallery

Personal static photo gallery with Atom feed and upload API. Single-user. No database, no framework — just a Go binary that watches a directory and regenerates a static site. An Android app in android/ posts to the same upload API.

Architecture

content/          # images + .toml sidecars (source of truth)
public/           # generated output (served by nginx / --serve flag)
templates/        # embedded HTML templates
static/           # embedded CSS + JS
android/          # Kotlin/Compose uploader app, own flake

The binary does three things at once:

  • Watches content/ via fsnotify and rebuilds on any change
  • Serves a multipart upload API at POST /upload
  • In dev mode (--serve), also serves public/ as static files

Routes: GET /upload (HTML upload form), POST /upload (multipart), GET /manage (list of photos), GET/POST /manage/edit/{slug} (edit caption and date), GET/POST /manage/delete/{slug} (confirm, then delete), GET /health.

In production nginx sits in front: serves public/ directly, proxies /upload and /manage to localhost, htpasswd on both. Note /health is not proxied — nginx only forwards location /upload and location /manage, so anything outside the box can only reach those two.

Managing photos

manage.go holds the /manage handlers, kept apart from the upload code in server.go. The pages are rendered on request from the sidecars (not generated into public/), and nothing on the public site links to them. Constraints worth knowing before touching it:

  • An edit only rewrites the .toml sidecar. Any key other than caption and date in a hand-written sidecar is dropped, same as on upload.
  • Slugs from the URL are matched against the photos actually in content/, never joined into a path.
  • sameOrigin compares the Origin header against -base-url, because behind nginx the Host header is the upstream address. A wrong -base-url therefore breaks saving in browsers that send no Sec-Fetch-Site.
  • Delete is permanent and takes two steps: GET /manage/delete/{slug} only shows a confirmation page, the POST does the work. Generator.Delete removes the image and sidecar from content/ first, then the four renditions and public/photo/<slug>/, all under the build mutex. There is no trash folder — recovery means restoring from a backup.
  • Listing pages are put right by the rebuild that follows, which also prunes what a delete or a date change leaves behind: renderDayPages drops public/day/<date>/ for days without photos, and prunePages drops pager pages past the last one. Renditions of photos removed from content/ by hand (not via /manage) are still not pruned.

Stack

  • Go — stdlib + BurntSushi/toml, fsnotify/fsnotify, golang.org/x/image
  • Templates — html/template, embedded via embed.FS
  • Images — four renditions per photo: original copy, 600×600 centre-cropped square thumbnail, a 1600px-longest-side "medium" for the detail page, and a 1200px "og" for link previews. Catmull-Rom resize. Outputs are skipped when newer than the source.
  • Feed — Atom (encoding/xml)
  • NixOS — flake.nix + module.nix, exports nixosModules.default

Sidecar format

caption = "Tempelhof at sunrise"
date    = "2026-06-14"

Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.

Design

  • Gruvbox dark hard colour scheme
  • Crimson Pro (serif) for headings and captions — configurable via -serif-family / -fonts-url
  • Manjari for Malayalam text (bio line), via -ml-family
  • Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via -page-size (default 60; NixOS services.photogallery.pageSize):
    • Timeline is the site root by default (/, /page/2/, …). Photos grouped by calendar day: one ISO date heading (2026-06-14) + count per day, then the square grid. groupByDay collapses the newest-first list into consecutive same-day runs; chunkDayGroups packs whole days into pages targeting pageSize, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
    • Grid (/grid/, /grid/page/2/, …) is the flat square grid, linked from the profile header.
    • Each timeline date heading links to a single-day page (/day/<YYYY-MM-DD>/) holding just that day's photos. renderDayPages writes one per groupByDay run; the ISO label doubles as the URL slug.
  • Which view owns the site root is set by -default-view timeline|grid (NixOS services.photogallery.defaultView). The other view moves under /<name>/: with -default-view grid the grid is / and the timeline is /timeline/. viewBase and pageURL in generator.go derive every page path and pager link from that, and the templates take the cross-links as GridURL / TimelineURL — never hardcode /grid/ or /. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch.
  • Single photo page with sidebar and newer/older navigation

Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls in WhatsApp, Telegram, etc. generateOG writes a dedicated public/og/ rendition — 1200px longest side, quality laddered down until under ~250 KB, because WhatsApp drops the preview above ~300 KB and the medium rendition can exceed that. The og:image URL is absolute ({{.BaseURL}}/...); both crawlers ignore relative image paths, and previews only work against the deployed -base-url, never http://localhost. og:image:width/height come from DecodeConfig on the OG file, read even when the file itself was skipped as up to date. Both platforms cache per URL — after a deploy, re-scrape via @webpagebot (Telegram) or Facebook's sharing debugger (WhatsApp).

Photo page navigation

static/photo-nav.js handles arrow keys and touch swipe. Both read their destinations from the [data-nav="newer"] / [data-nav="older"] anchors the template renders, so a photo with no sibling in that direction has nothing to find and the gesture rubber-bands. If the script fails to load the links still work.

Swipe left → older, right → newer, matching ArrowRight/ArrowLeft. Constraints worth knowing before touching it:

  • Swipes starting within 24px of a screen edge are ignored — that strip is the browser's own back-gesture.
  • Only pointerType === "touch", so desktop drag-to-save is untouched.
  • Axis locks once after 10px so vertical drags stay scrolls; touch-action: pan-y tells the compositor the same.
  • The image is wrapped in an <a> to the full-size original, so the click trailing a swipe is suppressed or it opens a new tab.
  • EXIT_MS in the JS must stay in sync with the transition duration in style.css.

Anything added under static/ is embedded and copied to public/static/ automatically — assets.go embeds the whole directory and copyStatic() walks every entry, so new CSS/JS needs no generator change.

Running locally

nix develop
go mod tidy
go run . --serve
# gallery: http://localhost:8080
# upload:  http://localhost:8080/upload

Android app

Kotlin + Jetpack Compose uploader: pick or share a photo, add caption and date, post. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See android/README.md for the detail.

Its own flake, deliberately separate from the root one so NixOS consumers of nixosModules.default don't pull the Android SDK into their lock:

cd android
nix develop
./gradlew assembleRelease   # app/build/outputs/apk/release/app-release.apk

The server needs no changes to serve it — every gap (slug collisions, EXIF, HEIC, size caps) is closed client-side. Release builds need android/keystore.properties; without it the signing config is skipped and only debug builds work.

NixOS module usage

inputs.photogallery.url = "github:youruser/photogallery";
# in modules:
inputs.photogallery.nixosModules.default
# in configuration.nix:
services.photogallery = {
  enable  = true;
  baseURL = "https://photos.yourdomain.tld";
  nginx   = {
    enable       = true;
    domain       = "photos.yourdomain.tld";
    htpasswdFile = "/etc/nginx/.htpasswd-gallery";
  };
};

Bootstrap (first nix build)

go mod tidy        # generates go.sum — commit it
nix build          # fails, prints correct vendorHash
# paste hash into flake.nix
nix build          # succeeds

Key files

File Purpose
main.go Flags, wires generator + watcher + server
generator.go Scans content dir, renders HTML + Atom feed, generates thumbs/mediums
server.go Upload API + optional static file serving
manage.go /manage list and edit handlers
watcher.go fsnotify watcher with 500ms debounce
assets.go //go:embed for templates and static
templates/timeline.html Site root: photos grouped by day, paginated
templates/grid.html Flat square grid at /grid/, paginated
templates/day.html Single-day page at /day/<date>/
templates/photo.html Single photo page, neighbour links and prefetch
templates/manage.html /manage: list of photos with edit links
templates/edit.html /manage/edit/{slug}: caption and date form
static/photo-nav.js Arrow-key and swipe navigation
module.nix NixOS module (systemd service, tmpfiles, nginx vhost)
flake.nix buildGoModule package + devShell + nixosModules
android/flake.nix Android SDK dev shell (separate from the root flake)

Gotchas

  • .gitignore patterns here must be anchored. An unanchored photogallery also matches the Kotlin package directory android/app/src/main/java/ws/inflo/photogallery/ and silently swallows every source file in it.

What's not done yet

  • ActivityPub federation (planned later — start with RSS/Atom)
  • Multi-image upload
  • Pruning renditions and photo pages of photos deleted from content/ by hand (delete via /manage cleans up after itself)
  • Any auth beyond nginx htpasswd
  • The Android app has never been run on real hardware
  • Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part