Add albums: group photos by free-text tags

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/<slug>/ (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.
This commit is contained in:
puttaalu committed 2026-10-08 16:14:09 +02:00
1 parent f33bdd55b1
commit a42abfab48
11 files changed
+465 -2

No files matched your search

+20
View File
@@ -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/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
- Single photo page with sidebar and newer/older navigation
### 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/<slug>/` (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/<date>/` |
| `templates/album.html` | Album (tag) page at `/album/<slug>/`, 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) |
+204
View File
@@ -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/<slug>/,
// 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)
}
+14 -1
View File
@@ -43,6 +43,7 @@ var ogQualityLadder = []int{85, 78, 70, 62}
type PhotoMeta struct {
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...),
})
}
+9 -1
View File
@@ -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,6 +96,13 @@ 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())
@@ -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
}
+54
View File
@@ -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;
}
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Tag.Name}} · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">Timeline</a>
<a href="/albums/" class="rss-a">← Albums</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<section class="tl-day">
<h2 class="tl-date">{{.Tag.Name}}<span class="tl-count">{{.Count}}</span></h2>
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
</a>
{{end}}
</div>
</section>
{{if or .PrevURL .NextURL}}
<nav class="pager">
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
</nav>
{{end}}
</div>
</main>
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script>
(function () {
const btn = document.querySelector('[data-load-more]');
if (!btn) return;
const grid = document.querySelector('.pgrid');
let loading = false;
async function loadMore(url) {
if (loading) return;
loading = true;
try {
const res = await fetch(url);
if (!res.ok) return;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('.pgrid > a').forEach(t => grid.appendChild(t));
const nextBtn = doc.querySelector('[data-load-more]');
if (nextBtn) btn.href = nextBtn.getAttribute('href');
else btn.remove();
} finally {
loading = false;
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
loadMore(btn.href);
});
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && document.body.contains(btn)) {
loadMore(btn.href);
}
}, { rootMargin: '400px' });
io.observe(btn);
})();
</script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Albums · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">← Timeline</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<section class="tl-day">
<h2 class="tl-date">Albums<span class="tl-count">{{len .Albums}}</span></h2>
{{if .Albums}}
<div class="album-list">
{{range .Albums}}
<a href="/album/{{.Tag.Slug}}/" class="album-chip">
{{.Tag.Name}}<span class="album-chip-count">{{.Count}}</span>
</a>
{{end}}
</div>
{{else}}
<p class="album-empty">No albums yet — add tags to a photo to create one.</p>
{{end}}
</section>
</div>
</main>
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
</body>
</html>
+1
View File
@@ -24,6 +24,7 @@
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">Timeline</a>
<a href="/albums/" class="rss-a">Albums</a>
<a href="/feed.xml" class="rss-a">
<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"/>
+5
View File
@@ -58,6 +58,11 @@
</div>
<p class="dcaption">{{.Photo.Caption}}</p>
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
{{if .Photo.Tags}}
<div class="dtags">
{{range .Photo.Tags}}<a href="/album/{{.Slug}}/" class="tag">{{.Name}}</a>{{end}}
</div>
{{end}}
<p class="dfull"><a href="/{{.Photo.File}}" download>↓ Full size</a></p>
</div>
</div>
+1
View File
@@ -24,6 +24,7 @@
<p class="pcount"><strong>{{.Total}}</strong> posts</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="/albums/" class="rss-a">Albums</a>
<a href="/feed.xml" class="rss-a">
<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"/>
+7
View File
@@ -161,6 +161,11 @@
<input type="date" id="date">
</div>
<div class="field">
<label for="tags">Tags</label>
<input type="text" id="tags" placeholder="comma-separated, lowercase" autocomplete="off">
</div>
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
<div id="feedback"></div>
</div>
@@ -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';