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
+469 -6

No files matched your search

+11 -3
View File
@@ -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
}