4ddf5adf24
The day-grouped timeline is now the site root (/, /page/N/); the flat square grid moves to /grid/ (/grid/page/N/). Both are linked from the profile header, separated by a divider. Timeline pages pack whole day-groups until they reach the page size, never splitting a day across a page boundary, so appended pages never repeat a date heading — the infinite-scroll handler relies on this. Page size is configurable via -page-size (default 60), threaded through the Generator and exposed as services.photogallery.pageSize in the NixOS module.
154 lines
6.7 KiB
Markdown
154 lines
6.7 KiB
Markdown
# 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 /health`.
|
||
|
||
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`.
|
||
|
||
## Stack
|
||
|
||
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`
|
||
- **Templates** — `html/template`, embedded via `embed.FS`
|
||
- **Images** — three renditions per photo: original copy, 600×600 centre-cropped square thumbnail, and a 1600px-longest-side "medium" for the detail page. 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
|
||
|
||
```toml
|
||
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** (`/`, `/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.
|
||
- Single photo page with sidebar and newer/older navigation
|
||
|
||
### 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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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
|
||
|
||
```nix
|
||
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)
|
||
|
||
```bash
|
||
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 |
|
||
| `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/photo.html` | Single photo page, neighbour links and prefetch |
|
||
| `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
|
||
- Delete/edit via UI
|
||
- 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
|