The chosen view is published at /, the other under /timeline/ or /grid/. Page paths, pager links and the cross-links between the views are derived from the setting. The directory named after the default view is removed on build, since it can only be a leftover from before a switch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9.7 KiB
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 servespublic/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 /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
.tomlsidecar. Any key other thancaptionanddatein 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. sameOrigincompares theOriginheader against-base-url, because behind nginx theHostheader is the upstream address. A wrong-base-urltherefore breaks saving in browsers that send noSec-Fetch-Site.- Changing a date moves the photo to another day page, but the old
public/day/<date>/is not removed — the generator never prunespublic/.
Stack
- Go — stdlib +
BurntSushi/toml,fsnotify/fsnotify,golang.org/x/image - Templates —
html/template, embedded viaembed.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, exportsnixosModules.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; NixOSservices.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.groupByDaycollapses the newest-first list into consecutive same-day runs;chunkDayGroupspacks whole days into pages targetingpageSize, 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.renderDayPageswrites one pergroupByDayrun; the ISO label doubles as the URL slug.
- Timeline is the site root by default (
- Which view owns the site root is set by
-default-view timeline|grid(NixOSservices.photogallery.defaultView). The other view moves under/<name>/: with-default-view gridthe grid is/and the timeline is/timeline/.viewBaseandpageURLingenerator.goderive every page path and pager link from that, and the templates take the cross-links asGridURL/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. Root-level/page/N/beyond the new page count is not pruned. - Single photo page with sidebar and newer/older navigation
Link previews
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-ytells 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_MSin the JS must stay in sync with the transition duration instyle.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
.gitignorepatterns here must be anchored. An unanchoredphotogalleryalso matches the Kotlin package directoryandroid/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
- Delete via UI (edit is done, see Managing photos)
- Pruning
public/when a photo is deleted or its date changes - 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