From a42abfab48a6fb8245331875371292b87fdef2e7 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Thu, 8 Oct 2026 16:14:09 +0200 Subject: [PATCH] Add albums: group photos by free-text tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An album is a tag. Photos carry lowercase free-text tags in their .toml sidecar (`tags = [...]`), parallel to the existing day grouping — no database, all derived at build time. - albums.go: parseTags (lowercase/trim/dedupe, slug via sanitiseSlug), groupByTag mirroring groupByDay, and rendering for album pages, the index, and the JSON API. Slug collisions between distinct names are suffixed and logged. - Routes: /album// (paginated square grid per tag), /albums/ (index with counts), /tags.json (machine-readable tag list, served statically like feed.xml). - POST /upload gains a `tags` field written into the sidecar; the web upload form gains a tags input. - Tags shown on each photo page linking to their album; an Albums link in the timeline/grid header. --- CLAUDE.md | 20 ++++ albums.go | 204 ++++++++++++++++++++++++++++++++++++++++ generator.go | 19 +++- server.go | 14 ++- static/style.css | 54 +++++++++++ templates/album.html | 96 +++++++++++++++++++ templates/albums.html | 54 +++++++++++ templates/grid.html | 1 + templates/photo.html | 5 + templates/timeline.html | 1 + templates/upload.html | 7 ++ 11 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 albums.go create mode 100644 templates/album.html create mode 100644 templates/albums.html diff --git a/CLAUDE.md b/CLAUDE.md index 510224b..9d15173 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,10 +36,15 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload` ```toml caption = "Tempelhof at sunrise" date = "2026-06-14" +tags = ["berlin", "sunrise"] ``` Missing sidecar → filename as caption, today as date. Photos sort newest first by that date. +`tags` is optional, free-text, and defines albums (see below). Tags are +normalized to lowercase on load/upload (`parseTags` in `albums.go`); a tag may +contain spaces (`"berlin trip"`), and its URL slug is derived with `sanitiseSlug`. + ## Design - Gruvbox dark hard colour scheme @@ -51,6 +56,18 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first - Each timeline date heading links to a **single-day page** (`/day//`) 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 +### Albums (tags) + +A second grouping axis, parallel to days: an album is just a tag (`albums.go`). +`groupByTag` mirrors `groupByDay`, and `renderAlbumPages` writes a paginated +square-grid album per tag at `/album//` (like the grid view). `/albums/` +is the index of every tag with counts; `/tags.json` is the machine-readable tag +list (`[{tag, slug, count}]`, served statically like `feed.xml`). Tags show on +each photo page and link to their album; an **Albums** link sits in the +timeline/grid header. Tags are assigned at upload via the `tags` field on +`POST /upload` (comma-separated) or by editing a sidecar's `tags`. All derived at +build time — no database. + ### Link previews Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls @@ -142,12 +159,15 @@ nix build # succeeds |---|---| | `main.go` | Flags, wires generator + watcher + server | | `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums | +| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` | | `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/day.html` | Single-day page at `/day//` | +| `templates/album.html` | Album (tag) page at `/album//`, paginated | +| `templates/albums.html` | Album index at `/albums/` | | `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) | diff --git a/albums.go b/albums.go new file mode 100644 index 0000000..23ca4ed --- /dev/null +++ b/albums.go @@ -0,0 +1,204 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// Tag is a normalized album tag: a lowercase free-text Name and its URL Slug. +type Tag struct { + Name string + Slug string +} + +// Album is all photos sharing one tag, newest first. +type Album struct { + Tag Tag + Count int + Photos []Photo +} + +// parseTags normalizes raw tag strings into unique Tags. Each input may itself +// be comma-separated (as typed in the upload form), so entries are split on +// commas, lowercased, trimmed, de-duplicated by name, and given a URL slug via +// sanitiseSlug. Entries that are empty or slug to nothing are dropped. +func parseTags(raw ...string) []Tag { + var tags []Tag + seen := map[string]bool{} + for _, entry := range raw { + for _, part := range strings.Split(entry, ",") { + name := strings.ToLower(strings.TrimSpace(part)) + if name == "" || seen[name] { + continue + } + slug := sanitiseSlug(name) + if slug == "" { + continue + } + seen[name] = true + tags = append(tags, Tag{Name: name, Slug: slug}) + } + } + return tags +} + +// groupByTag collapses photos (already sorted newest first) into one Album per +// distinct tag name, preserving photo order within each album. Albums are +// returned sorted alphabetically by tag name. If two different names produce the +// same URL slug, the later one is suffixed (-2, -3, …) and a warning is logged, +// so album URLs stay unique. +func groupByTag(photos []Photo) []Album { + byName := map[string][]Photo{} + for _, p := range photos { + for _, t := range p.Tags { + byName[t.Name] = append(byName[t.Name], p) + } + } + + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, name) + } + sort.Strings(names) + + slugOwner := map[string]string{} + albums := make([]Album, 0, len(names)) + for _, name := range names { + slug := sanitiseSlug(name) + if owner, taken := slugOwner[slug]; taken && owner != name { + base := slug + for n := 2; ; n++ { + cand := base + "-" + strconv.Itoa(n) + if _, t := slugOwner[cand]; !t { + slug = cand + break + } + } + log.Printf("album slug collision: %q and %q both slug to %q; using %q for the latter", + owner, name, base, slug) + } + slugOwner[slug] = name + ps := byName[name] + albums = append(albums, Album{ + Tag: Tag{Name: name, Slug: slug}, + Count: len(ps), + Photos: ps, + }) + } + return albums +} + +// baseData returns the profile-header and site-wide fields every page template +// expects, so the album templates stay in step with the others. +func (g *Generator) baseData() map[string]any { + return map[string]any{ + "BaseURL": g.baseURL, + "Year": time.Now().Year(), + "Author": g.author, + "Handle": g.handle, + "Bio": g.bio, + "BioAlt": g.bioAlt, + "Initial": g.initial(), + "FontsURL": g.fontsURL, + "SerifFamily": g.serifFamily, + "MlFamily": g.mlFamily, + } +} + +// renderAlbumPages writes one paginated album per tag under public/album//, +// a flat square grid like the grid view. total is the site-wide photo count, for +// the profile header. +func (g *Generator) renderAlbumPages(albums []Album, total int) error { + for _, al := range albums { + albumDir := filepath.Join(g.outputDir, "album", al.Tag.Slug) + if err := os.MkdirAll(albumDir, 0755); err != nil { + return err + } + base := "/album/" + al.Tag.Slug + "/" + + totalPages := (al.Count + g.pageSize - 1) / g.pageSize + if totalPages == 0 { + totalPages = 1 + } + + for i := 0; i < totalPages; i++ { + start := i * g.pageSize + end := start + g.pageSize + if end > al.Count { + end = al.Count + } + + dst := filepath.Join(albumDir, "index.html") + if i > 0 { + dir := filepath.Join(albumDir, "page", strconv.Itoa(i+1)) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + dst = filepath.Join(dir, "index.html") + } + + prev := "" + next := "" + switch i { + case 0: + // no prev + case 1: + prev = base + default: + prev = base + "page/" + strconv.Itoa(i) + "/" + } + if i+1 < totalPages { + next = base + "page/" + strconv.Itoa(i+2) + "/" + } + + data := g.baseData() + data["Tag"] = al.Tag + data["Count"] = al.Count + data["Photos"] = al.Photos[start:end] + data["Total"] = total + data["Page"] = i + 1 + data["TotalPages"] = totalPages + data["PrevURL"] = prev + data["NextURL"] = next + if err := g.render("album.html", dst, data); err != nil { + return err + } + } + } + return nil +} + +// renderAlbumsIndex writes public/albums/index.html — every tag with its count, +// linking to each album. +func (g *Generator) renderAlbumsIndex(albums []Album, total int) error { + data := g.baseData() + data["Albums"] = albums + data["Total"] = total + return g.render("albums.html", filepath.Join(g.outputDir, "albums", "index.html"), data) +} + +type tagJSON struct { + Tag string `json:"tag"` + Slug string `json:"slug"` + Count int `json:"count"` +} + +// renderTagsJSON writes public/tags.json — the machine-readable tag list, served +// statically like feed.xml. +func (g *Generator) renderTagsJSON(albums []Album) error { + out := make([]tagJSON, 0, len(albums)) + for _, a := range albums { + out = append(out, tagJSON{Tag: a.Tag.Name, Slug: a.Tag.Slug, Count: a.Count}) + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(g.outputDir, "tags.json"), b, 0644) +} diff --git a/generator.go b/generator.go index 57db661..a739a0b 100644 --- a/generator.go +++ b/generator.go @@ -41,8 +41,9 @@ var ogQualityLadder = []int{85, 78, 70, 62} // PhotoMeta is the structure of a .toml sidecar file. type PhotoMeta struct { - Caption string `toml:"caption"` - Date string `toml:"date"` // YYYY-MM-DD + Caption string `toml:"caption"` + Date string `toml:"date"` // YYYY-MM-DD + Tags []string `toml:"tags,omitempty"` // free-text, lowercased; each tag is an album } // Photo is the in-memory representation of one image. @@ -56,6 +57,7 @@ type Photo struct { OGHeight int Caption string Date time.Time + Tags []Tag // albums this photo belongs to } // DayGroup is a run of photos sharing the same calendar day, for the timeline. @@ -121,7 +123,7 @@ func (g *Generator) Build() error { return err } - for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} { + for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { return err } @@ -142,6 +144,16 @@ func (g *Generator) Build() error { if err := g.renderDayPages(photos); err != nil { return err } + albums := groupByTag(photos) + if err := g.renderAlbumPages(albums, len(photos)); err != nil { + return err + } + if err := g.renderAlbumsIndex(albums, len(photos)); err != nil { + return err + } + if err := g.renderTagsJSON(albums); err != nil { + return err + } for i := range photos { var newer, older *Photo if i > 0 { @@ -209,6 +221,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) { OG: "og/" + slug + ".jpg", Caption: meta.Caption, Date: date, + Tags: parseTags(meta.Tags...), }) } diff --git a/server.go b/server.go index 6e6138c..1cdd457 100644 --- a/server.go +++ b/server.go @@ -33,7 +33,7 @@ func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Se func (s *Server) Listen() error { mux := http.NewServeMux() - mux.HandleFunc("GET /upload", s.handleUploadPage) + mux.HandleFunc("GET /upload", s.handleUploadPage) mux.HandleFunc("POST /upload", s.handleUpload) mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") @@ -67,6 +67,7 @@ func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) { // image — the image file (jpg or png) // caption — human-readable caption (optional, defaults to filename) // date — YYYY-MM-DD (optional, defaults to today) +// tags — comma-separated album tags (optional; lowercased, deduped) func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -95,11 +96,18 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { caption = strings.TrimSuffix(header.Filename, ext) } + // Normalize the tags once here so the sidecar stores clean lowercase names; + // the generator derives album slugs from them at build time. + var tags []string + for _, t := range parseTags(r.FormValue("tags")) { + tags = append(tags, t.Name) + } + slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext)) if slug == "" { slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli()) } - imgPath := filepath.Join(s.contentDir, slug+ext) + imgPath := filepath.Join(s.contentDir, slug+ext) tomlPath := filepath.Join(s.contentDir, slug+".toml") out, err := os.Create(imgPath) @@ -121,7 +129,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { return } defer tf.Close() - if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil { + if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr, Tags: tags}); err != nil { http.Error(w, "could not write metadata", http.StatusInternalServerError) return } diff --git a/static/style.css b/static/style.css index 54537cc..d7cee56 100644 --- a/static/style.css +++ b/static/style.css @@ -422,3 +422,57 @@ body { .dlayout { flex-direction: column; } .dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); } } + +/* ── Tags / albums ─────────────────────────────────────────── */ + +.dtags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 14px; +} + +.tag, +.album-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + background: var(--bg0); + border: 1px solid var(--bg2); + color: var(--fg3); + font-family: monospace; + font-size: 12px; + text-decoration: none; + border-radius: 2px; + transition: color 0.15s, border-color 0.15s; +} + +.tag:hover, +.album-chip:hover { + color: var(--aqua); + border-color: var(--fg4); +} + +.album-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.album-chip { + font-size: 14px; + padding: 7px 14px; +} + +.album-chip-count { + color: var(--fg4); + font-size: 11px; +} + +.album-empty { + color: var(--fg4); + font-family: var(--serif); + margin-top: 16px; +} diff --git a/templates/album.html b/templates/album.html new file mode 100644 index 0000000..7f4ce95 --- /dev/null +++ b/templates/album.html @@ -0,0 +1,96 @@ + + + + + + {{.Tag.Name}} · Photo Gallery + + + + + + + + + +
+
+
+ {{.Initial}} +
+
+

{{.Author}}

+

{{.Handle}}

+

{{.Total}} posts

+

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

+ Timeline + ← Albums +
+
+
+ +
+
+
+

{{.Tag.Name}}{{.Count}}

+
+ {{range .Photos}} + + {{.Caption}} + + {{end}} +
+
+ + {{if or .PrevURL .NextURL}} + + {{end}} +
+
+ +
+ © 2000–{{.Year}} {{.Author}}. All rights reserved. +
+ + + + diff --git a/templates/albums.html b/templates/albums.html new file mode 100644 index 0000000..c434856 --- /dev/null +++ b/templates/albums.html @@ -0,0 +1,54 @@ + + + + + + Albums · Photo Gallery + + + + + + + + + +
+
+
+ {{.Initial}} +
+
+

{{.Author}}

+

{{.Handle}}

+

{{.Total}} posts

+

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

+ ← Timeline +
+
+
+ +
+
+
+

Albums{{len .Albums}}

+ {{if .Albums}} +
+ {{range .Albums}} + + {{.Tag.Name}}{{.Count}} + + {{end}} +
+ {{else}} +

No albums yet — add tags to a photo to create one.

+ {{end}} +
+
+
+ +
+ © 2000–{{.Year}} {{.Author}}. All rights reserved. +
+ + diff --git a/templates/grid.html b/templates/grid.html index 5cba5e8..fc775e5 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -24,6 +24,7 @@

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

Timeline + Albums

{{.Photo.Caption}}

{{.Photo.Date.Format "Jan 02, 2006"}}

+ {{if .Photo.Tags}} +
+ {{end}}

↓ Full size

diff --git a/templates/timeline.html b/templates/timeline.html index 89f9242..6f3d063 100644 --- a/templates/timeline.html +++ b/templates/timeline.html @@ -24,6 +24,7 @@

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

Grid + Albums
+ + +
+
@@ -256,6 +261,7 @@ form.append('image', file); form.append('caption', document.getElementById('caption').value); form.append('date', dateInput.value); + form.append('tags', document.getElementById('tags').value); try { const res = await fetch('/upload', { method: 'POST', body: form }); @@ -268,6 +274,7 @@ prompt.style.display = 'flex'; dropZone.classList.remove('has-image'); document.getElementById('caption').value = ''; + document.getElementById('tags').value = ''; submitBtn.disabled = true; } else { feedback.className = 'err';