Files
photog/CLAUDE.md
T
foxandClaude Fable 5.1 0268a7d4c9 feat(manage): add /manage pages to edit caption and date
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar, which triggers a rebuild. The pages sit behind the same
htpasswd as /upload and are not linked from the public site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 06:43:44 +02:00

182 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 /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`.
- Changing a date moves the photo to another day page, but the old `public/day/<date>/` is not removed — the generator never prunes `public/`.
## 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
```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.
- 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.
- 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-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 |
| `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
- 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