Add /manage (edit, delete) and -default-view #1
@@ -19,9 +19,19 @@ The binary does three things at once:
|
|||||||
- Serves a multipart upload API at `POST /upload`
|
- Serves a multipart upload API at `POST /upload`
|
||||||
- In dev mode (`--serve`), also serves `public/` as static files
|
- In dev mode (`--serve`), also serves `public/` as static files
|
||||||
|
|
||||||
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`.
|
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`, and with `-manage`: `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete).
|
||||||
|
|
||||||
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`.
|
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`. They are only registered when the binary runs with `-manage` (NixOS `services.photogallery.manage.enable`, which also adds the nginx location); without it the paths 404 and there is no edit or delete code path at all. 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
|
## Stack
|
||||||
|
|
||||||
@@ -46,9 +56,10 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
|
|||||||
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
|
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
|
||||||
- Manjari for Malayalam text (bio line), via `-ml-family`
|
- 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`):
|
- 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).
|
- **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.
|
- **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.
|
- 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
|
- Single photo page with sidebar and newer/older navigation
|
||||||
|
|
||||||
### Link previews
|
### Link previews
|
||||||
@@ -143,12 +154,15 @@ nix build # succeeds
|
|||||||
| `main.go` | Flags, wires generator + watcher + server |
|
| `main.go` | Flags, wires generator + watcher + server |
|
||||||
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
||||||
| `server.go` | Upload API + optional static file serving |
|
| `server.go` | Upload API + optional static file serving |
|
||||||
|
| `manage.go` | `/manage` list and edit handlers |
|
||||||
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||||||
| `assets.go` | `//go:embed` for templates and static |
|
| `assets.go` | `//go:embed` for templates and static |
|
||||||
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
||||||
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
||||||
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
||||||
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
|
| `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 |
|
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
||||||
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
||||||
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
|
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
|
||||||
@@ -162,7 +176,7 @@ nix build # succeeds
|
|||||||
|
|
||||||
- ActivityPub federation (planned later — start with RSS/Atom)
|
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||||
- Multi-image upload
|
- Multi-image upload
|
||||||
- Delete/edit via UI
|
- Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans up after itself)
|
||||||
- Any auth beyond nginx htpasswd
|
- Any auth beyond nginx htpasswd
|
||||||
- The Android app has never been run on real hardware
|
- 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
|
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ services.photogallery = {
|
|||||||
serifFamily = "EB Garamond";
|
serifFamily = "EB Garamond";
|
||||||
mlFamily = "Manjari";
|
mlFamily = "Manjari";
|
||||||
|
|
||||||
|
# Optional: edit and delete published photos at /manage (off by default)
|
||||||
|
manage.enable = true;
|
||||||
|
|
||||||
# Optional: built-in nginx vhost
|
# Optional: built-in nginx vhost
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = true;
|
enable = true;
|
||||||
@@ -101,9 +104,12 @@ nix-shell -p apacheHttpd --run \
|
|||||||
| `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
|
| `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
|
||||||
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
|
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
|
||||||
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
|
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
|
||||||
|
| `pageSize` | `60` | Photos per page on the timeline and grid views |
|
||||||
|
| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` |
|
||||||
|
| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd |
|
||||||
| `nginx.enable` | `false` | Configure an nginx vhost |
|
| `nginx.enable` | `false` | Configure an nginx vhost |
|
||||||
| `nginx.domain` | — | Domain for the vhost |
|
| `nginx.domain` | — | Domain for the vhost |
|
||||||
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` |
|
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
|
||||||
| `nginx.enableACME` | `true` | Enable Let's Encrypt |
|
| `nginx.enableACME` | `true` | Enable Let's Encrypt |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -138,3 +144,20 @@ curl -u user:password \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Or open `https://photos.yourdomain.tld/upload` in a browser.
|
Or open `https://photos.yourdomain.tld/upload` in a browser.
|
||||||
|
|
||||||
|
## Editing and deleting photos
|
||||||
|
|
||||||
|
The pages are off by default: start the binary with `-manage` (NixOS:
|
||||||
|
`services.photogallery.manage.enable = true`). Then open
|
||||||
|
`https://photos.yourdomain.tld/manage` in a browser. It lists every
|
||||||
|
photo; *Edit* opens a form for the caption and date, and saving rewrites the
|
||||||
|
photo's `.toml` sidecar, which triggers a rebuild.
|
||||||
|
|
||||||
|
*Delete* asks for confirmation, then removes the image, its sidecar and
|
||||||
|
everything generated from them. This is permanent — there is no trash folder,
|
||||||
|
so keep a backup of the content directory.
|
||||||
|
|
||||||
|
`/manage` sits behind the same htpasswd as `/upload`, and nothing on the
|
||||||
|
public site links to it. If you run your own nginx instead of the NixOS
|
||||||
|
module, add a `location /manage` block alongside the `/upload` one — without
|
||||||
|
it the path is not proxied and returns 404.
|
||||||
|
|||||||
+142
-38
@@ -27,6 +27,11 @@ const (
|
|||||||
mediumMaxSize = 1600
|
mediumMaxSize = 1600
|
||||||
defaultPageSize = 60
|
defaultPageSize = 60
|
||||||
|
|
||||||
|
// The two paginated views. The one chosen as -default-view is published
|
||||||
|
// at the site root, the other under /<name>/.
|
||||||
|
viewTimeline = "timeline"
|
||||||
|
viewGrid = "grid"
|
||||||
|
|
||||||
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
|
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
|
||||||
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
|
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
|
||||||
// reported to drop the preview entirely once the image passes ~300 KB, so
|
// reported to drop the preview entirely once the image passes ~300 KB, so
|
||||||
@@ -78,15 +83,19 @@ type Generator struct {
|
|||||||
serifFamily string
|
serifFamily string
|
||||||
mlFamily string
|
mlFamily string
|
||||||
pageSize int
|
pageSize int
|
||||||
|
defaultView string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator {
|
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int, defaultView string) *Generator {
|
||||||
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||||
if pageSize < 1 {
|
if pageSize < 1 {
|
||||||
pageSize = defaultPageSize
|
pageSize = defaultPageSize
|
||||||
}
|
}
|
||||||
|
if defaultView != viewGrid {
|
||||||
|
defaultView = viewTimeline
|
||||||
|
}
|
||||||
return &Generator{
|
return &Generator{
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
@@ -99,6 +108,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
|
|||||||
serifFamily: serifFamily,
|
serifFamily: serifFamily,
|
||||||
mlFamily: mlFamily,
|
mlFamily: mlFamily,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
|
defaultView: defaultView,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +120,23 @@ func (g *Generator) initial() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewBase returns the URL prefix a view is published under: "" for the
|
||||||
|
// default view, which owns the site root, and "/<view>" for the other one.
|
||||||
|
func (g *Generator) viewBase(view string) string {
|
||||||
|
if view == g.defaultView {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/" + view
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageURL returns the URL of the 1-based page n of a view rooted at base.
|
||||||
|
func pageURL(base string, n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return base + "/"
|
||||||
|
}
|
||||||
|
return base + "/page/" + strconv.Itoa(n) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
// Build performs a full rebuild of the site. It is safe to call concurrently;
|
// Build performs a full rebuild of the site. It is safe to call concurrently;
|
||||||
// concurrent calls are serialised by a mutex.
|
// concurrent calls are serialised by a mutex.
|
||||||
func (g *Generator) Build() error {
|
func (g *Generator) Build() error {
|
||||||
@@ -121,7 +148,7 @@ func (g *Generator) Build() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} {
|
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} {
|
||||||
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -133,6 +160,13 @@ func (g *Generator) Build() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The default view lives at the site root, so a directory named after it
|
||||||
|
// can only be left over from a run with the other -default-view. Drop it,
|
||||||
|
// or the old pages would stay reachable and go stale.
|
||||||
|
if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := g.renderTimelinePages(photos); err != nil {
|
if err := g.renderTimelinePages(photos); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -165,6 +199,48 @@ func (g *Generator) Build() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes a photo for good: its image and sidecar in the content
|
||||||
|
// directory, then everything generated from them. Pages that list the photo
|
||||||
|
// are put right by the next Build. It returns os.ErrNotExist when no photo
|
||||||
|
// has that slug.
|
||||||
|
func (g *Generator) Delete(slug string) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
photos, err := g.loadPhotos()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, p := range photos {
|
||||||
|
if p.Slug != slug {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
// The source goes first: if it cannot be removed, the photo stays
|
||||||
|
// fully intact rather than half deleted.
|
||||||
|
if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} {
|
||||||
|
err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel)))
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug is safe to join from here on: it matched a directory entry.
|
||||||
|
if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug))
|
||||||
|
}
|
||||||
|
|
||||||
// loadPhotos scans the content directory and returns photos sorted newest first.
|
// loadPhotos scans the content directory and returns photos sorted newest first.
|
||||||
func (g *Generator) loadPhotos() ([]Photo, error) {
|
func (g *Generator) loadPhotos() ([]Photo, error) {
|
||||||
entries, err := os.ReadDir(g.contentDir)
|
entries, err := os.ReadDir(g.contentDir)
|
||||||
@@ -407,17 +483,17 @@ func generateOG(src, dst string, maxDim int) error {
|
|||||||
return os.WriteFile(dst, buf.Bytes(), 0644)
|
return os.WriteFile(dst, buf.Bytes(), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderGridPages writes the paginated square-grid view under public/grid/:
|
// renderGridPages writes the paginated square-grid view: index.html and
|
||||||
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
|
// page/N/index.html under the grid's base (the site root when the grid is the
|
||||||
// slices for progressive pagination. The grid is the secondary view; the
|
// default view, /grid/ otherwise), chunking photos into pageSize slices for
|
||||||
// timeline is the site root.
|
// progressive pagination.
|
||||||
func (g *Generator) renderGridPages(photos []Photo) error {
|
func (g *Generator) renderGridPages(photos []Photo) error {
|
||||||
total := len(photos)
|
total := len(photos)
|
||||||
totalPages := (total + g.pageSize - 1) / g.pageSize
|
totalPages := (total + g.pageSize - 1) / g.pageSize
|
||||||
if totalPages == 0 {
|
if totalPages == 0 {
|
||||||
totalPages = 1
|
totalPages = 1
|
||||||
}
|
}
|
||||||
gridDir := filepath.Join(g.outputDir, "grid")
|
base := g.viewBase(viewGrid)
|
||||||
|
|
||||||
for i := 0; i < totalPages; i++ {
|
for i := 0; i < totalPages; i++ {
|
||||||
start := i * g.pageSize
|
start := i * g.pageSize
|
||||||
@@ -427,31 +503,24 @@ func (g *Generator) renderGridPages(photos []Photo) error {
|
|||||||
}
|
}
|
||||||
slice := photos[start:end]
|
slice := photos[start:end]
|
||||||
|
|
||||||
dst := filepath.Join(gridDir, "index.html")
|
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
|
||||||
if i > 0 {
|
|
||||||
dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1))
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dst = filepath.Join(dir, "index.html")
|
dst := filepath.Join(dir, "index.html")
|
||||||
}
|
|
||||||
|
|
||||||
prev := ""
|
prev := ""
|
||||||
next := ""
|
next := ""
|
||||||
switch i {
|
if i > 0 {
|
||||||
case 0:
|
prev = pageURL(base, i)
|
||||||
// no prev
|
|
||||||
case 1:
|
|
||||||
prev = "/grid/"
|
|
||||||
default:
|
|
||||||
prev = "/grid/page/" + strconv.Itoa(i) + "/"
|
|
||||||
}
|
}
|
||||||
if i+1 < totalPages {
|
if i+1 < totalPages {
|
||||||
next = "/grid/page/" + strconv.Itoa(i+2) + "/"
|
next = pageURL(base, i+2)
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Photos": slice,
|
"Photos": slice,
|
||||||
|
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
|
||||||
"Total": total,
|
"Total": total,
|
||||||
"Page": i + 1,
|
"Page": i + 1,
|
||||||
"TotalPages": totalPages,
|
"TotalPages": totalPages,
|
||||||
@@ -472,7 +541,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return g.prunePages(base, totalPages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderPhotoPage writes public/photo/<slug>/index.html.
|
// renderPhotoPage writes public/photo/<slug>/index.html.
|
||||||
@@ -544,39 +613,35 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
|
|||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTimelinePages writes the day-grouped timeline as the site root:
|
// renderTimelinePages writes the day-grouped timeline: index.html and
|
||||||
// index.html and page/N/index.html. Photos are grouped by day, then packed
|
// page/N/index.html under the timeline's base (the site root when the
|
||||||
|
// timeline is the default view). Photos are grouped by day, then packed
|
||||||
// into pages of roughly pageSize without splitting a day across pages.
|
// into pages of roughly pageSize without splitting a day across pages.
|
||||||
func (g *Generator) renderTimelinePages(photos []Photo) error {
|
func (g *Generator) renderTimelinePages(photos []Photo) error {
|
||||||
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
|
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
|
||||||
totalPages := len(pages)
|
totalPages := len(pages)
|
||||||
|
|
||||||
|
base := g.viewBase(viewTimeline)
|
||||||
|
|
||||||
for i, groups := range pages {
|
for i, groups := range pages {
|
||||||
dst := filepath.Join(g.outputDir, "index.html")
|
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
|
||||||
if i > 0 {
|
|
||||||
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dst = filepath.Join(dir, "index.html")
|
dst := filepath.Join(dir, "index.html")
|
||||||
}
|
|
||||||
|
|
||||||
prev := ""
|
prev := ""
|
||||||
next := ""
|
next := ""
|
||||||
switch i {
|
if i > 0 {
|
||||||
case 0:
|
prev = pageURL(base, i)
|
||||||
// no prev
|
|
||||||
case 1:
|
|
||||||
prev = "/"
|
|
||||||
default:
|
|
||||||
prev = "/page/" + strconv.Itoa(i) + "/"
|
|
||||||
}
|
}
|
||||||
if i+1 < totalPages {
|
if i+1 < totalPages {
|
||||||
next = "/page/" + strconv.Itoa(i+2) + "/"
|
next = pageURL(base, i+2)
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Groups": groups,
|
"Groups": groups,
|
||||||
|
"GridURL": pageURL(g.viewBase(viewGrid), 1),
|
||||||
"Total": len(photos),
|
"Total": len(photos),
|
||||||
"Page": i + 1,
|
"Page": i + 1,
|
||||||
"TotalPages": totalPages,
|
"TotalPages": totalPages,
|
||||||
@@ -597,19 +662,58 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return g.prunePages(base, totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prunePages removes the pager pages of a view past its last one. They are
|
||||||
|
// left behind when the photo count shrinks or the views swap places, and
|
||||||
|
// would keep showing photos that are gone.
|
||||||
|
func (g *Generator) prunePages(base string, totalPages int) error {
|
||||||
|
// Page 1 is the view's own root, never a leftover: start past it even
|
||||||
|
// if a caller reports no pages at all.
|
||||||
|
for n := max(totalPages, 1) + 1; ; n++ {
|
||||||
|
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
|
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
|
||||||
// holding just that day's photos. Timeline date headings link here.
|
// holding just that day's photos. Timeline date headings link here. Pages of
|
||||||
|
// days that no longer have photos, after a delete or a date change, are
|
||||||
|
// removed.
|
||||||
func (g *Generator) renderDayPages(photos []Photo) error {
|
func (g *Generator) renderDayPages(photos []Photo) error {
|
||||||
for _, grp := range groupByDay(photos) {
|
groups := groupByDay(photos)
|
||||||
|
|
||||||
|
live := make(map[string]bool, len(groups))
|
||||||
|
for _, grp := range groups {
|
||||||
|
live[grp.day] = true
|
||||||
|
}
|
||||||
|
dayDir := filepath.Join(g.outputDir, "day")
|
||||||
|
entries, err := os.ReadDir(dayDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() && !live[e.Name()] {
|
||||||
|
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, grp := range groups {
|
||||||
dir := filepath.Join(g.outputDir, "day", grp.day)
|
dir := filepath.Join(g.outputDir, "day", grp.day)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
|
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
|
||||||
"Date": grp.Label,
|
"Date": grp.Label,
|
||||||
|
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
|
||||||
"Count": grp.Count,
|
"Count": grp.Count,
|
||||||
"Photos": grp.Photos,
|
"Photos": grp.Photos,
|
||||||
"Total": len(photos),
|
"Total": len(photos),
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildSite builds a site of three photos spread over two days, with a page
|
||||||
|
// size of two so both views need a second page, and returns the output dir.
|
||||||
|
func buildSite(t *testing.T, output, defaultView string) {
|
||||||
|
t.Helper()
|
||||||
|
content := t.TempDir()
|
||||||
|
|
||||||
|
var img bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for slug, date := range map[string]string{
|
||||||
|
"one": "2026-06-14",
|
||||||
|
"two": "2026-06-14",
|
||||||
|
"three": "2026-06-10",
|
||||||
|
} {
|
||||||
|
writeFile(t, filepath.Join(content, slug+".jpg"), img.Bytes())
|
||||||
|
writeFile(t, filepath.Join(content, slug+".toml"),
|
||||||
|
[]byte("caption = \""+slug+"\"\ndate = \""+date+"\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
g := NewGenerator(content, output, "https://photos.example.com",
|
||||||
|
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, defaultView)
|
||||||
|
if err := g.Build(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path string, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPage(t *testing.T, output, rel string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline pages carry day sections, grid pages do not.
|
||||||
|
func isTimeline(page string) bool {
|
||||||
|
return strings.Contains(page, `class="tl-day"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultViewOwnsSiteRoot(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
defaultView string
|
||||||
|
gridAt string // URL of the grid's first page
|
||||||
|
timelineAt string // URL of the timeline's first page
|
||||||
|
}{
|
||||||
|
{viewTimeline, "/grid/", "/"},
|
||||||
|
{viewGrid, "/", "/timeline/"},
|
||||||
|
{"nonsense", "/grid/", "/"}, // unknown values fall back to the timeline
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.defaultView, func(t *testing.T) {
|
||||||
|
output := t.TempDir()
|
||||||
|
buildSite(t, output, tt.defaultView)
|
||||||
|
|
||||||
|
timeline := readPage(t, output, tt.timelineAt)
|
||||||
|
grid := readPage(t, output, tt.gridAt)
|
||||||
|
if !isTimeline(timeline) {
|
||||||
|
t.Errorf("%s is not the timeline", tt.timelineAt)
|
||||||
|
}
|
||||||
|
if isTimeline(grid) {
|
||||||
|
t.Errorf("%s is not the grid", tt.gridAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The views link to each other, and to their own second page.
|
||||||
|
for page, want := range map[string]string{
|
||||||
|
timeline: `href="` + tt.gridAt + `" class="rss-a">Grid`,
|
||||||
|
grid: `href="` + tt.timelineAt + `" class="rss-a">Timeline`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(page, want) {
|
||||||
|
t.Errorf("missing link %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for first, base := range map[string]string{
|
||||||
|
timeline: strings.TrimSuffix(tt.timelineAt, "/"),
|
||||||
|
grid: strings.TrimSuffix(tt.gridAt, "/"),
|
||||||
|
} {
|
||||||
|
next := base + "/page/2/"
|
||||||
|
if !strings.Contains(first, `href="`+next+`" data-load-more`) {
|
||||||
|
t.Errorf("first page does not link to %s", next)
|
||||||
|
}
|
||||||
|
second := readPage(t, output, next)
|
||||||
|
if !strings.Contains(second, `class="pager-newer" href="`+base+`/"`) {
|
||||||
|
t.Errorf("%s does not link back to %s/", next, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day pages lead back to the timeline, wherever it lives.
|
||||||
|
day := readPage(t, output, "/day/2026-06-14/")
|
||||||
|
if !strings.Contains(day, `href="`+tt.timelineAt+`" class="rss-a">← Timeline`) {
|
||||||
|
t.Errorf("day page does not link to the timeline at %s", tt.timelineAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) {
|
||||||
|
output := t.TempDir()
|
||||||
|
|
||||||
|
buildSite(t, output, viewTimeline)
|
||||||
|
if _, err := os.Stat(filepath.Join(output, "grid", "index.html")); err != nil {
|
||||||
|
t.Fatalf("grid view missing before the switch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSite(t, output, viewGrid)
|
||||||
|
if _, err := os.Stat(filepath.Join(output, "grid")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("stale /grid/ survived the switch to -default-view grid")
|
||||||
|
}
|
||||||
|
if isTimeline(readPage(t, output, "/")) {
|
||||||
|
t.Errorf("site root is still the timeline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShrinkingSiteDropsLeftoverPages(t *testing.T) {
|
||||||
|
output := t.TempDir()
|
||||||
|
buildSite(t, output, viewTimeline) // three photos, page size two
|
||||||
|
|
||||||
|
content := t.TempDir()
|
||||||
|
var img bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(content, "one.jpg"), img.Bytes())
|
||||||
|
writeFile(t, filepath.Join(content, "one.toml"),
|
||||||
|
[]byte("caption = \"one\"\ndate = \"2026-06-14\"\n"))
|
||||||
|
|
||||||
|
// The same output directory, now fed a single photo: one page per view,
|
||||||
|
// one day.
|
||||||
|
g := NewGenerator(content, output, "https://photos.example.com",
|
||||||
|
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, viewTimeline)
|
||||||
|
if err := g.Build(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gone := range []string{"page/2", "grid/page/2", "day/2026-06-10"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(gone))); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("leftover %s survived", gone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, kept := range []string{"index.html", "grid/index.html", "day/2026-06-14/index.html"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(kept))); err != nil {
|
||||||
|
t.Errorf("%s is missing: %v", kept, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,15 +20,21 @@ func main() {
|
|||||||
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
|
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
|
||||||
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
|
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
|
||||||
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
|
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
|
||||||
|
manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)")
|
||||||
|
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *defaultView != viewTimeline && *defaultView != viewGrid {
|
||||||
|
log.Fatalf("-default-view must be %q or %q, got %q", viewTimeline, viewGrid, *defaultView)
|
||||||
|
}
|
||||||
|
|
||||||
for _, d := range []string{*contentDir, *outputDir} {
|
for _, d := range []string{*contentDir, *outputDir} {
|
||||||
if err := os.MkdirAll(d, 0755); err != nil {
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
log.Fatalf("mkdir %s: %v", d, err)
|
log.Fatalf("mkdir %s: %v", d, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize)
|
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize, *defaultView)
|
||||||
|
|
||||||
if err := g.Build(); err != nil {
|
if err := g.Build(); err != nil {
|
||||||
log.Printf("initial build: %v", err)
|
log.Printf("initial build: %v", err)
|
||||||
@@ -41,6 +47,6 @@ func main() {
|
|||||||
defer w.Close()
|
defer w.Close()
|
||||||
go w.Watch()
|
go w.Watch()
|
||||||
|
|
||||||
s := NewServer(*port, *contentDir, *outputDir, *serve, g)
|
s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
|
||||||
log.Fatal(s.Listen())
|
log.Fatal(s.Listen())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The /manage pages edit and delete photos that are already published. Like
|
||||||
|
// /upload they carry no auth of their own: in production nginx puts them
|
||||||
|
// behind htpasswd, and nothing on the public site links to them.
|
||||||
|
|
||||||
|
// maxEditBody caps the edit form. It carries a caption and a date, nothing else.
|
||||||
|
const maxEditBody = 64 << 10
|
||||||
|
|
||||||
|
// handleManagePage lists every photo with links to edit and delete it.
|
||||||
|
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
photos, err := s.generator.loadPhotos()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("manage: %v", err)
|
||||||
|
http.Error(w, "could not read photos", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := s.managePageData()
|
||||||
|
data["Photos"] = photos
|
||||||
|
if saved := r.URL.Query().Get("saved"); saved != "" {
|
||||||
|
for _, p := range photos {
|
||||||
|
if p.Slug == saved {
|
||||||
|
data["Saved"] = p.Caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["Deleted"] = r.URL.Query().Get("deleted")
|
||||||
|
s.renderManage(w, "manage.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEditPage shows the edit form for one photo, prefilled from its sidecar.
|
||||||
|
func (s *Server) handleEditPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.managePageData()
|
||||||
|
data["Photo"] = photo
|
||||||
|
s.renderManage(w, "edit.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEdit accepts the edit form with fields:
|
||||||
|
//
|
||||||
|
// caption — human-readable caption (empty falls back to the slug, as on upload)
|
||||||
|
// date — YYYY-MM-DD
|
||||||
|
//
|
||||||
|
// It rewrites the photo's .toml sidecar; the image itself is never touched.
|
||||||
|
func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.sameOrigin(r) {
|
||||||
|
http.Error(w, "cross-site request refused", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxEditBody)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caption := strings.TrimSpace(r.PostFormValue("caption"))
|
||||||
|
if caption == "" {
|
||||||
|
caption = photo.Slug
|
||||||
|
}
|
||||||
|
dateStr := r.PostFormValue("date")
|
||||||
|
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
|
||||||
|
http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode first and write in one go, so a rebuild racing this request
|
||||||
|
// never reads a half-written sidecar.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := toml.NewEncoder(&buf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
|
||||||
|
http.Error(w, "could not write metadata", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tomlPath := filepath.Join(s.contentDir, photo.Slug+".toml")
|
||||||
|
if err := os.WriteFile(tomlPath, buf.Bytes(), 0644); err != nil {
|
||||||
|
log.Printf("write %s: %v", tomlPath, err)
|
||||||
|
http.Error(w, "could not save metadata", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build() is mutex-guarded so calling it here and from the watcher is safe.
|
||||||
|
go func() {
|
||||||
|
if err := s.generator.Build(); err != nil {
|
||||||
|
log.Printf("rebuild after edit: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeletePage asks for confirmation before a photo is deleted. Deleting
|
||||||
|
// is a separate page rather than a button in the list so that it always takes
|
||||||
|
// two deliberate steps.
|
||||||
|
func (s *Server) handleDeletePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.managePageData()
|
||||||
|
data["Photo"] = photo
|
||||||
|
s.renderManage(w, "delete.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDelete removes a photo for good: image, sidecar and everything
|
||||||
|
// generated from them. There is no undo.
|
||||||
|
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.sameOrigin(r) {
|
||||||
|
http.Error(w, "cross-site request refused", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.generator.Delete(photo.Slug); err != nil {
|
||||||
|
log.Printf("delete %s: %v", photo.Slug, err)
|
||||||
|
http.Error(w, "could not delete photo", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("deleted %s (%q)", photo.Slug, photo.Caption)
|
||||||
|
|
||||||
|
// Build() is mutex-guarded so calling it here and from the watcher is safe.
|
||||||
|
go func() {
|
||||||
|
if err := s.generator.Build(); err != nil {
|
||||||
|
log.Printf("rebuild after delete: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPhoto looks slug up among the photos in the content directory. Matching
|
||||||
|
// against real entries, rather than building a path from the URL, is what
|
||||||
|
// keeps a crafted slug from reaching outside the content directory.
|
||||||
|
func (s *Server) findPhoto(slug string) (Photo, bool) {
|
||||||
|
photos, err := s.generator.loadPhotos()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("manage: %v", err)
|
||||||
|
return Photo{}, false
|
||||||
|
}
|
||||||
|
for _, p := range photos {
|
||||||
|
if p.Slug == slug {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Photo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameOrigin reports whether a form post came from the gallery's own pages.
|
||||||
|
// Basic auth credentials are replayed by the browser on every request to the
|
||||||
|
// host, so without this check any other website could submit the edit or
|
||||||
|
// delete form on a logged-in user's behalf.
|
||||||
|
//
|
||||||
|
// The Origin header is compared against -base-url as well as the Host header:
|
||||||
|
// behind nginx the Host that reaches this server is the proxy's upstream
|
||||||
|
// address, not the public name. Requests with neither header come from
|
||||||
|
// non-browser clients such as curl, which cannot be driven cross-site.
|
||||||
|
func (s *Server) sameOrigin(r *http.Request) bool {
|
||||||
|
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
|
||||||
|
return site == "same-origin" || site == "none"
|
||||||
|
}
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if origin == s.generator.baseURL {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
return err == nil && u.Host == r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// managePageData returns the template fields shared by the manage pages.
|
||||||
|
func (s *Server) managePageData() map[string]any {
|
||||||
|
g := s.generator
|
||||||
|
return map[string]any{
|
||||||
|
"FontsURL": g.fontsURL,
|
||||||
|
"SerifFamily": g.serifFamily,
|
||||||
|
"MlFamily": g.mlFamily,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderManage renders into a buffer first so a template error becomes a
|
||||||
|
// clean 500 instead of a truncated page.
|
||||||
|
func (s *Server) renderManage(w http.ResponseWriter, tmpl string, data map[string]any) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := s.generator.tmpl.ExecuteTemplate(&buf, tmpl, data); err != nil {
|
||||||
|
log.Printf("render %s: %v", tmpl, err)
|
||||||
|
http.Error(w, "could not render page", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// Always reflect the sidecars as they are now, never a cached copy.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
buf.WriteTo(w)
|
||||||
|
}
|
||||||
+355
@@ -0,0 +1,355 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestServer returns a server over a content directory holding one photo,
|
||||||
|
// "sunrise", with a sidecar.
|
||||||
|
func newTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Not t.TempDir(): an edit kicks off a background rebuild that can still
|
||||||
|
// be writing when the test ends, and TempDir fails the test if its
|
||||||
|
// cleanup finds the directory busy.
|
||||||
|
root, err := os.MkdirTemp("", "photog-test-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.RemoveAll(root) })
|
||||||
|
|
||||||
|
content := filepath.Join(root, "content")
|
||||||
|
output := filepath.Join(root, "public")
|
||||||
|
for _, d := range []string{content, output} {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var img bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(content, "sunrise.jpg"), img.Bytes())
|
||||||
|
writeFile(t, filepath.Join(content, "sunrise.toml"),
|
||||||
|
[]byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n"))
|
||||||
|
|
||||||
|
g := NewGenerator(content, output, "https://photos.example.com",
|
||||||
|
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
|
||||||
|
return NewServer("0", content, output, false, true, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMeta(t *testing.T, s *Server, slug string) PhotoMeta {
|
||||||
|
t.Helper()
|
||||||
|
var meta PhotoMeta
|
||||||
|
if _, err := toml.DecodeFile(filepath.Join(s.contentDir, slug+".toml"), &meta); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// postEdit submits the edit form for slug and returns the response.
|
||||||
|
func postEdit(s *Server, slug string, form url.Values, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest("POST", "/manage/edit/"+slug, strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
for k, v := range headers {
|
||||||
|
r.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
r.SetPathValue("slug", slug)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEdit(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagePageListsPhotos(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleManagePage(w, httptest.NewRequest("GET", "/manage", nil))
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Tempelhof at sunrise", "2026-06-14", `href="/manage/edit/sunrise"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("manage page is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditPagePrefillsForm(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := httptest.NewRequest("GET", "/manage/edit/sunrise", nil)
|
||||||
|
r.SetPathValue("slug", "sunrise")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEditPage(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{`value="Tempelhof at sunrise"`, `value="2026-06-14"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("edit page is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditRewritesSidecar(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
form := url.Values{"caption": {`Tempelhof, "golden hour"`}, "date": {"2026-06-15"}}
|
||||||
|
w := postEdit(s, "sunrise", form, nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if loc := w.Header().Get("Location"); loc != "/manage?saved=sunrise" {
|
||||||
|
t.Errorf("Location = %q", loc)
|
||||||
|
}
|
||||||
|
meta := readMeta(t, s, "sunrise")
|
||||||
|
if meta.Caption != `Tempelhof, "golden hour"` || meta.Date != "2026-06-15" {
|
||||||
|
t.Errorf("sidecar = %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEmptyCaptionFallsBackToSlug(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
w := postEdit(s, "sunrise", url.Values{"caption": {" "}, "date": {"2026-06-14"}}, nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if meta := readMeta(t, s, "sunrise"); meta.Caption != "sunrise" {
|
||||||
|
t.Errorf("caption = %q, want the slug", meta.Caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditRejectsBadInput(t *testing.T) {
|
||||||
|
valid := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
slug string
|
||||||
|
form url.Values
|
||||||
|
headers map[string]string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unknown photo", "nope", valid, nil, http.StatusNotFound},
|
||||||
|
{"path traversal", "../sunrise", valid, nil, http.StatusNotFound},
|
||||||
|
{"malformed date", "sunrise", url.Values{"caption": {"changed"}, "date": {"15.06.2026"}}, nil, http.StatusBadRequest},
|
||||||
|
{"missing date", "sunrise", url.Values{"caption": {"changed"}}, nil, http.StatusBadRequest},
|
||||||
|
{"cross-site fetch", "sunrise", valid, map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
|
||||||
|
{"foreign origin", "sunrise", valid, map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
w := postEdit(s, tt.slug, tt.form, tt.headers)
|
||||||
|
if w.Code != tt.want {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, tt.want)
|
||||||
|
}
|
||||||
|
if meta := readMeta(t, s, "sunrise"); meta.Caption != "Tempelhof at sunrise" || meta.Date != "2026-06-14" {
|
||||||
|
t.Errorf("sidecar was changed: %+v", meta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditAcceptsOwnOrigin(t *testing.T) {
|
||||||
|
form := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string]string
|
||||||
|
}{
|
||||||
|
{"same-origin fetch metadata", map[string]string{"Sec-Fetch-Site": "same-origin"}},
|
||||||
|
// Behind nginx the Host header is the upstream address, so only
|
||||||
|
// -base-url can vouch for the public origin.
|
||||||
|
{"origin equal to base URL", map[string]string{"Origin": "https://photos.example.com"}},
|
||||||
|
{"origin equal to host", map[string]string{"Origin": "http://example.com"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
if w := postEdit(s, "sunrise", form, tt.headers); w.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("status = %d, want 303: %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a
|
||||||
|
// day of its own, so deleting it must also take that day's page away.
|
||||||
|
func twoPhotoServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s := newTestServer(t)
|
||||||
|
|
||||||
|
var img bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes())
|
||||||
|
writeFile(t, filepath.Join(s.contentDir, "dusk.toml"),
|
||||||
|
[]byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n"))
|
||||||
|
|
||||||
|
if err := s.generator.Build(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoFiles lists every file and directory that belongs to one photo.
|
||||||
|
func photoFiles(s *Server, slug, day string) []string {
|
||||||
|
return []string{
|
||||||
|
filepath.Join(s.contentDir, slug+".jpg"),
|
||||||
|
filepath.Join(s.contentDir, slug+".toml"),
|
||||||
|
filepath.Join(s.outputDir, "images", slug+".jpg"),
|
||||||
|
filepath.Join(s.outputDir, "thumbs", slug+".jpg"),
|
||||||
|
filepath.Join(s.outputDir, "medium", slug+".jpg"),
|
||||||
|
filepath.Join(s.outputDir, "og", slug+".jpg"),
|
||||||
|
filepath.Join(s.outputDir, "photo", slug),
|
||||||
|
filepath.Join(s.outputDir, "day", day),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil)
|
||||||
|
for k, v := range headers {
|
||||||
|
r.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
r.SetPathValue("slug", slug)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleDelete(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePageAsksFirst(t *testing.T) {
|
||||||
|
s := twoPhotoServer(t)
|
||||||
|
r := httptest.NewRequest("GET", "/manage/delete/dusk", nil)
|
||||||
|
r.SetPathValue("slug", "dusk")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleDeletePage(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `action="/manage/delete/dusk"`) {
|
||||||
|
t.Errorf("confirmation page has no delete form")
|
||||||
|
}
|
||||||
|
// Looking at the confirmation page must not delete anything.
|
||||||
|
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
|
||||||
|
if _, err := os.Stat(f); err != nil {
|
||||||
|
t.Errorf("%s is gone after a GET: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRemovesPhotoEverywhere(t *testing.T) {
|
||||||
|
s := twoPhotoServer(t)
|
||||||
|
w := postDelete(s, "dusk", nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" {
|
||||||
|
t.Errorf("Location = %q", loc)
|
||||||
|
}
|
||||||
|
// The handler rebuilds in the background; build here to have a
|
||||||
|
// finished site to look at.
|
||||||
|
if err := s.generator.Build(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
|
||||||
|
if _, err := os.Stat(f); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("%s survived the delete", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range photoFiles(s, "sunrise", "2026-06-14") {
|
||||||
|
if _, err := os.Stat(f); err != nil {
|
||||||
|
t.Errorf("the other photo lost %s: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, page := range []string{"index.html", "grid/index.html", "feed.xml"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(s.outputDir, page))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "dusk") {
|
||||||
|
t.Errorf("%s still mentions the deleted photo", page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRejectsBadInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
slug string
|
||||||
|
headers map[string]string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unknown photo", "nope", nil, http.StatusNotFound},
|
||||||
|
{"path traversal", "../dusk", nil, http.StatusNotFound},
|
||||||
|
{"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
|
||||||
|
{"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := twoPhotoServer(t)
|
||||||
|
if w := postDelete(s, tt.slug, tt.headers); w.Code != tt.want {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, tt.want)
|
||||||
|
}
|
||||||
|
for _, slug := range []string{"dusk", "sunrise"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(s.contentDir, slug+".jpg")); err != nil {
|
||||||
|
t.Errorf("%s was deleted: %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManageRoutesNeedTheFlag(t *testing.T) {
|
||||||
|
for _, manage := range []bool{false, true} {
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.manage = manage
|
||||||
|
mux := s.routes()
|
||||||
|
|
||||||
|
want := http.StatusNotFound
|
||||||
|
if manage {
|
||||||
|
want = http.StatusOK
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||||
|
if w.Code != want {
|
||||||
|
t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The delete POST only does something with the flag: without it the
|
||||||
|
// photo must survive, with it (no Origin header, as from curl) it goes.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil))
|
||||||
|
_, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg"))
|
||||||
|
present := err == nil
|
||||||
|
if present == manage {
|
||||||
|
t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present)
|
||||||
|
}
|
||||||
|
// /upload and /health are there regardless.
|
||||||
|
for _, path := range []string{"/upload", "/health"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -82,6 +82,14 @@ in {
|
|||||||
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
|
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defaultView = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "timeline" "grid" ];
|
||||||
|
default = "timeline";
|
||||||
|
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
|
||||||
|
};
|
||||||
|
|
||||||
|
manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)";
|
||||||
|
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ in {
|
|||||||
htpasswdFile = lib.mkOption {
|
htpasswdFile = lib.mkOption {
|
||||||
type = lib.types.path;
|
type = lib.types.path;
|
||||||
example = "/etc/nginx/.htpasswd-gallery";
|
example = "/etc/nginx/.htpasswd-gallery";
|
||||||
description = "Path to htpasswd file protecting the /upload endpoint.";
|
description = "Path to htpasswd file protecting the /upload and /manage endpoints.";
|
||||||
};
|
};
|
||||||
|
|
||||||
enableACME = lib.mkOption {
|
enableACME = lib.mkOption {
|
||||||
@@ -134,7 +142,7 @@ in {
|
|||||||
User = "photogallery";
|
User = "photogallery";
|
||||||
Group = "photogallery";
|
Group = "photogallery";
|
||||||
|
|
||||||
ExecStart = lib.escapeShellArgs [
|
ExecStart = lib.escapeShellArgs ([
|
||||||
"${pkg}/bin/photogallery"
|
"${pkg}/bin/photogallery"
|
||||||
"--content" cfg.contentDir
|
"--content" cfg.contentDir
|
||||||
"--output" cfg.outputDir
|
"--output" cfg.outputDir
|
||||||
@@ -148,7 +156,8 @@ in {
|
|||||||
"--serif-family" cfg.serifFamily
|
"--serif-family" cfg.serifFamily
|
||||||
"--ml-family" cfg.mlFamily
|
"--ml-family" cfg.mlFamily
|
||||||
"--page-size" (toString cfg.pageSize)
|
"--page-size" (toString cfg.pageSize)
|
||||||
];
|
"--default-view" cfg.defaultView
|
||||||
|
] ++ lib.optional cfg.manage.enable "--manage");
|
||||||
|
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
@@ -184,6 +193,12 @@ in {
|
|||||||
basicAuthFile = cfg.nginx.htpasswdFile;
|
basicAuthFile = cfg.nginx.htpasswdFile;
|
||||||
extraConfig = "client_max_body_size 20M;";
|
extraConfig = "client_max_body_size 20M;";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
locations."/manage" = lib.mkIf cfg.manage.enable {
|
||||||
|
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||||
|
basicAuth = "gallery";
|
||||||
|
basicAuthFile = cfg.nginx.htpasswdFile;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,26 +18,43 @@ type Server struct {
|
|||||||
contentDir string
|
contentDir string
|
||||||
outputDir string
|
outputDir string
|
||||||
serve bool
|
serve bool
|
||||||
|
manage bool
|
||||||
generator *Generator
|
generator *Generator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server {
|
func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
port: port,
|
port: port,
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
serve: serve,
|
serve: serve,
|
||||||
|
manage: manage,
|
||||||
generator: g,
|
generator: g,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Listen() error {
|
// routes builds the handler. The /manage pages are only registered with
|
||||||
|
// -manage, so a service without the flag has no edit or delete code path at
|
||||||
|
// all and answers 404 there.
|
||||||
|
func (s *Server) routes() *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /upload", s.handleUploadPage)
|
mux.HandleFunc("GET /upload", s.handleUploadPage)
|
||||||
mux.HandleFunc("POST /upload", s.handleUpload)
|
mux.HandleFunc("POST /upload", s.handleUpload)
|
||||||
|
if s.manage {
|
||||||
|
mux.HandleFunc("GET /manage", s.handleManagePage)
|
||||||
|
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
|
||||||
|
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
|
||||||
|
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
|
||||||
|
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
|
||||||
|
}
|
||||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintln(w, "ok")
|
fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Listen() error {
|
||||||
|
mux := s.routes()
|
||||||
|
|
||||||
if s.serve {
|
if s.serve {
|
||||||
// Serve the generated site on the same port — for local development only.
|
// Serve the generated site on the same port — for local development only.
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
<p class="handle">{{.Handle}}</p>
|
<p class="handle">{{.Handle}}</p>
|
||||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
<a href="/" class="rss-a">← Timeline</a>
|
<a href="{{.TimelineURL}}" class="rss-a">← Timeline</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>Delete — {{.Photo.Caption}}</title>
|
||||||
|
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||||
|
.delete-page {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.delete-page .back-btn {
|
||||||
|
color: var(--fg3);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.delete-page .back-btn:hover { color: var(--fg); }
|
||||||
|
.delete-page h2 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 16px 0 24px;
|
||||||
|
}
|
||||||
|
.delete-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 340px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.delete-caption {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--fg);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.delete-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
margin: 4px 0 24px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.delete-warning {
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: #2e1d1d;
|
||||||
|
color: #fb4934;
|
||||||
|
border: 1px solid #5a2828;
|
||||||
|
}
|
||||||
|
.delete-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: #2e1d1d;
|
||||||
|
border: 1px solid #5a2828;
|
||||||
|
color: #fb4934;
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.delete-btn:hover { background: #5a2828; color: var(--fg); }
|
||||||
|
.cancel-btn {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
color: var(--fg2);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.cancel-btn:hover { background: var(--bg2); color: var(--fg); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="delete-page">
|
||||||
|
<a href="/manage" class="back-btn">← Manage</a>
|
||||||
|
<h2>Delete photo</h2>
|
||||||
|
|
||||||
|
<img class="delete-img" src="/{{.Photo.Medium}}" alt="">
|
||||||
|
<p class="delete-caption">{{.Photo.Caption}}</p>
|
||||||
|
<p class="delete-meta">{{.Photo.Date.Format "2006-01-02"}} · {{.Photo.Slug}}</p>
|
||||||
|
|
||||||
|
<p class="delete-warning">This removes the photo, its caption and every
|
||||||
|
page and image generated from it. It cannot be undone.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/manage/delete/{{.Photo.Slug}}">
|
||||||
|
<button class="delete-btn" type="submit">Delete permanently</button>
|
||||||
|
</form>
|
||||||
|
<a href="/manage" class="cancel-btn">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>Edit — {{.Photo.Caption}}</title>
|
||||||
|
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||||
|
.edit-page {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.edit-page .back-btn {
|
||||||
|
color: var(--fg3);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.edit-page .back-btn:hover { color: var(--fg); }
|
||||||
|
.edit-page h2 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 16px 0 24px;
|
||||||
|
}
|
||||||
|
.edit-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 340px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.edit-slug {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.field input[type="text"],
|
||||||
|
.field input[type="date"] {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.field input[type="text"] { font-family: var(--serif); }
|
||||||
|
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
|
||||||
|
.field input:focus { border-color: var(--fg4); }
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
color: var(--fg2);
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.submit-btn:hover { background: var(--bg2); color: var(--fg); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="edit-page">
|
||||||
|
<a href="/manage" class="back-btn">← Manage</a>
|
||||||
|
<h2>Edit photo</h2>
|
||||||
|
|
||||||
|
<img class="edit-img" src="/{{.Photo.Medium}}" alt="">
|
||||||
|
<p class="edit-slug">{{.Photo.Slug}}</p>
|
||||||
|
|
||||||
|
<!-- A plain form post, no script: the server answers with a redirect
|
||||||
|
back to /manage, so a reload never resubmits the edit. -->
|
||||||
|
<form method="post" action="/manage/edit/{{.Photo.Slug}}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="caption">Caption</label>
|
||||||
|
<input type="text" id="caption" name="caption" value="{{.Photo.Caption}}" autocomplete="off" autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="date">Date</label>
|
||||||
|
<input type="date" id="date" name="date" value="{{.Photo.Date.Format "2006-01-02"}}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="submit-btn" type="submit">Save changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
<p class="handle">{{.Handle}}</p>
|
<p class="handle">{{.Handle}}</p>
|
||||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
<a href="/" class="rss-a">Timeline</a>
|
<a href="{{.TimelineURL}}" class="rss-a">Timeline</a>
|
||||||
<a href="/feed.xml" class="rss-a">
|
<a href="/feed.xml" class="rss-a">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
||||||
<circle cx="2.5" cy="11.5" r="1.5"/>
|
<circle cx="2.5" cy="11.5" r="1.5"/>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>Manage — Photo Gallery</title>
|
||||||
|
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||||
|
.manage-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.manage-page .back-btn {
|
||||||
|
color: var(--fg3);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.manage-page .back-btn:hover { color: var(--fg); }
|
||||||
|
.manage-page h2 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 16px 0 6px;
|
||||||
|
}
|
||||||
|
.manage-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.saved {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1d2e1d;
|
||||||
|
color: #b8bb26;
|
||||||
|
border: 1px solid #344d28;
|
||||||
|
}
|
||||||
|
.mlist { list-style: none; }
|
||||||
|
.mrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.mrow img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg0);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.minfo { flex: 1; min-width: 0; }
|
||||||
|
.mcaption {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--fg);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.mdate {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.mactions {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.mactions a { color: var(--fg3); text-decoration: none; }
|
||||||
|
.mactions a:hover { color: var(--fg); }
|
||||||
|
.mactions .medit { color: var(--aqua); }
|
||||||
|
.mactions .mdelete { color: #fb4934; }
|
||||||
|
.mempty { color: var(--fg4); font-size: 15px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="manage-page">
|
||||||
|
<a href="/" class="back-btn">← Gallery</a>
|
||||||
|
<h2>Manage photos</h2>
|
||||||
|
<p class="manage-count">{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}</p>
|
||||||
|
|
||||||
|
{{if .Saved}}<p class="saved">✓ saved “{{.Saved}}”</p>{{end}}
|
||||||
|
{{if .Deleted}}<p class="saved">✓ deleted “{{.Deleted}}”</p>{{end}}
|
||||||
|
|
||||||
|
{{if .Photos}}
|
||||||
|
<ul class="mlist">
|
||||||
|
{{range .Photos}}
|
||||||
|
<li class="mrow">
|
||||||
|
<img src="/{{.Thumb}}" alt="" loading="lazy">
|
||||||
|
<div class="minfo">
|
||||||
|
<p class="mcaption">{{.Caption}}</p>
|
||||||
|
<p class="mdate">{{.Date.Format "2006-01-02"}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mactions">
|
||||||
|
<a href="/photo/{{.Slug}}/">View</a>
|
||||||
|
<a href="/manage/edit/{{.Slug}}" class="medit">Edit</a>
|
||||||
|
<a href="/manage/delete/{{.Slug}}" class="mdelete">Delete</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="mempty">No photos yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<p class="handle">{{.Handle}}</p>
|
<p class="handle">{{.Handle}}</p>
|
||||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
<a href="/grid/" class="rss-a">Grid</a>
|
<a href="{{.GridURL}}" class="rss-a">Grid</a>
|
||||||
<a href="/feed.xml" class="rss-a">
|
<a href="/feed.xml" class="rss-a">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
||||||
<circle cx="2.5" cy="11.5" r="1.5"/>
|
<circle cx="2.5" cy="11.5" r="1.5"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user