From a42abfab48a6fb8245331875371292b87fdef2e7 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Thu, 8 Oct 2026 16:14:09 +0200 Subject: [PATCH 01/16] 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'; From faa1e01c59292ceac4dc3a1fe8e525dc6947af7d Mon Sep 17 00:00:00 2001 From: puttaalu Date: Thu, 8 Oct 2026 16:19:25 +0200 Subject: [PATCH 02/16] Add a tags field to the Android and desktop uploaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each photo card gains a free-text tags input, sent as a raw comma-separated `tags` form field on upload. The server normalizes it (parseTags), so the clients stay dumb — no tag parsing on-device. - Android: PickedItem.tags + setTags; threaded through UploadQueue → UploadWorker → UploadClient's multipart. - Desktop: a tags QLineEdit per PhotoItemWidget; UploadClient.upload sends it. --- .../main/java/ws/inflo/photogallery/MainActivity.kt | 1 + .../java/ws/inflo/photogallery/net/UploadClient.kt | 2 ++ .../java/ws/inflo/photogallery/ui/PostScreen.kt | 13 +++++++++++++ .../ws/inflo/photogallery/ui/UploadViewModel.kt | 6 ++++++ .../java/ws/inflo/photogallery/work/UploadQueue.kt | 2 ++ .../java/ws/inflo/photogallery/work/UploadWorker.kt | 3 +++ desktop/mainwindow.cpp | 2 +- desktop/photoitemwidget.cpp | 10 ++++++++++ desktop/photoitemwidget.h | 2 ++ desktop/uploadclient.cpp | 3 ++- desktop/uploadclient.h | 2 +- 11 files changed, 43 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt index fdfac9e..be34219 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt @@ -81,6 +81,7 @@ class MainActivity : ComponentActivity() { ) }, onCaptionChange = viewModel::setCaption, + onTagsChange = viewModel::setTags, onDateChange = viewModel::setDate, onRemove = viewModel::removeItem, onSubmit = viewModel::submit, diff --git a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt index 7b23cc5..2bf28d1 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt @@ -60,6 +60,7 @@ class UploadClient( fileName: String, caption: String, date: String, + tags: String, ): UploadOutcome { val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) { "image/png".toMediaType() @@ -72,6 +73,7 @@ class UploadClient( .addFormDataPart("image", fileName, file.asRequestBody(mediaType)) .addFormDataPart("caption", caption) .addFormDataPart("date", date) + .addFormDataPart("tags", tags) .build() val request = Request.Builder() diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt index 60df797..64dc993 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt @@ -51,6 +51,7 @@ fun PostScreen( state: PostUiState, onPickImages: () -> Unit, onCaptionChange: (id: String, value: String) -> Unit, + onTagsChange: (id: String, value: String) -> Unit, onDateChange: (id: String, value: LocalDate) -> Unit, onRemove: (id: String) -> Unit, onSubmit: () -> Unit, @@ -107,6 +108,7 @@ fun PostScreen( ItemCard( item = item, onCaptionChange = { onCaptionChange(item.id, it) }, + onTagsChange = { onTagsChange(item.id, it) }, onEditDate = { dateEditFor = item }, onRemove = { onRemove(item.id) }, onOpenUrl = onOpenUrl, @@ -185,6 +187,7 @@ fun PostScreen( private fun ItemCard( item: PickedItem, onCaptionChange: (String) -> Unit, + onTagsChange: (String) -> Unit, onEditDate: () -> Unit, onRemove: () -> Unit, onOpenUrl: (String) -> Unit, @@ -220,6 +223,16 @@ private fun ItemCard( modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = item.tags, + onValueChange = onTagsChange, + label = { Text("Tags") }, + placeholder = { Text("comma-separated") }, + singleLine = true, + enabled = item.status !is ItemStatus.Uploading, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically) { OutlinedButton( onClick = onEditDate, diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt index 2bf4d37..182a57c 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt @@ -41,6 +41,7 @@ data class PickedItem( val source: Uri, val preview: Bitmap? = null, val caption: String = "", + val tags: String = "", // raw comma-separated; the server normalizes val date: LocalDate = LocalDate.now(), val status: ItemStatus = ItemStatus.Pending, ) @@ -114,6 +115,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { updateItem(id) { it.copy(caption = value) } } + fun setTags(id: String, value: String) { + updateItem(id) { it.copy(tags = value) } + } + fun setDate(id: String, value: LocalDate) { updateItem(id) { it.copy(date = value) } } @@ -159,6 +164,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { uri = Uri.fromFile(staged), caption = current.caption, date = current.date, + tags = current.tags, uniqueSuffix = item.id.take(6), ) observe(item.id, workId) diff --git a/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt index a35fae3..dbde648 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt @@ -34,6 +34,7 @@ object UploadQueue { uri: Uri, caption: String, date: LocalDate, + tags: String, uniqueSuffix: String, ): UUID { val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix @@ -45,6 +46,7 @@ object UploadQueue { .putString(UploadWorker.KEY_SLUG, slug) .putString(UploadWorker.KEY_CAPTION, caption) .putString(UploadWorker.KEY_DATE, date.format(ISO_DATE)) + .putString(UploadWorker.KEY_TAGS, tags) .build(), ) .setConstraints( diff --git a/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt b/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt index 70837c3..47e08be 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt @@ -38,6 +38,7 @@ class UploadWorker( val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure() val caption = inputData.getString(KEY_CAPTION).orEmpty() val date = inputData.getString(KEY_DATE).orEmpty() + val tags = inputData.getString(KEY_TAGS).orEmpty() val settings = SettingsStore(applicationContext) if (!settings.isConfigured) { @@ -66,6 +67,7 @@ class UploadWorker( fileName = prepared.file.name, caption = caption, date = date, + tags = tags, ) // Keep the staged and prepared copies only while a retry might still @@ -147,6 +149,7 @@ class UploadWorker( const val KEY_SLUG = "slug" const val KEY_CAPTION = "caption" const val KEY_DATE = "date" + const val KEY_TAGS = "tags" const val KEY_RESULT_SLUG = "result_slug" const val KEY_RESULT_URL = "result_url" diff --git a/desktop/mainwindow.cpp b/desktop/mainwindow.cpp index 0cfb829..7fbd453 100644 --- a/desktop/mainwindow.cpp +++ b/desktop/mainwindow.cpp @@ -313,7 +313,7 @@ void MainWindow::startItem(PhotoItemWidget *item) refreshPosting(); }); client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(), - caption, item->date()); + caption, item->date(), item->tags()); }); watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() { return ImagePrep::prepare(source, cacheDir, slug); diff --git a/desktop/photoitemwidget.cpp b/desktop/photoitemwidget.cpp index 1943d0c..ab7230d 100644 --- a/desktop/photoitemwidget.cpp +++ b/desktop/photoitemwidget.cpp @@ -47,6 +47,10 @@ PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent) m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd")); fields->addWidget(m_date); + m_tags = new QLineEdit; + m_tags->setPlaceholderText(QStringLiteral("tags (comma-separated)")); + fields->addWidget(m_tags); + m_statusLabel = new QLabel; m_statusLabel->setOpenExternalLinks(true); m_statusLabel->setWordWrap(true); @@ -70,6 +74,11 @@ QString PhotoItemWidget::date() const return m_date->date().toString(QStringLiteral("yyyy-MM-dd")); } +QString PhotoItemWidget::tags() const +{ + return m_tags->text(); +} + void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url) { m_status = status; @@ -79,6 +88,7 @@ void PhotoItemWidget::setStatus(Status status, const QString &message, const QSt const bool editable = status == Status::Pending || status == Status::Error; m_caption->setEnabled(editable); m_date->setEnabled(editable); + m_tags->setEnabled(editable); m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading); switch (status) { diff --git a/desktop/photoitemwidget.h b/desktop/photoitemwidget.h index 21f7023..da6a007 100644 --- a/desktop/photoitemwidget.h +++ b/desktop/photoitemwidget.h @@ -21,6 +21,7 @@ public: QString sourcePath() const { return m_sourcePath; } QString caption() const; QString date() const; // yyyy-MM-dd + QString tags() const; // raw comma-separated; the server normalizes Status status() const { return m_status; } void setStatus(Status status, const QString &message = {}, const QString &url = {}); @@ -35,6 +36,7 @@ private: QLabel *m_thumb = nullptr; QLineEdit *m_caption = nullptr; QDateEdit *m_date = nullptr; + QLineEdit *m_tags = nullptr; QLabel *m_statusLabel = nullptr; QPushButton *m_remove = nullptr; }; diff --git a/desktop/uploadclient.cpp b/desktop/uploadclient.cpp index 9f2d515..118460b 100644 --- a/desktop/uploadclient.cpp +++ b/desktop/uploadclient.cpp @@ -26,7 +26,7 @@ QByteArray UploadClient::authHeader() const } void UploadClient::upload(const QString &filePath, const QString &fileName, - const QString &caption, const QString &date) + const QString &caption, const QString &date, const QString &tags) { auto *file = new QFile(filePath); if (!file->open(QIODevice::ReadOnly)) { @@ -57,6 +57,7 @@ void UploadClient::upload(const QString &filePath, const QString &fileName, }; multiPart->append(textPart(QStringLiteral("caption"), caption)); multiPart->append(textPart(QStringLiteral("date"), date)); + multiPart->append(textPart(QStringLiteral("tags"), tags)); QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload"))); request.setRawHeader("Authorization", authHeader()); diff --git a/desktop/uploadclient.h b/desktop/uploadclient.h index 79ab7af..8bd3247 100644 --- a/desktop/uploadclient.h +++ b/desktop/uploadclient.h @@ -29,7 +29,7 @@ public: // The filename matters: server.go derives the slug from it, so re-sending // the same name overwrites the same post instead of duplicating it. void upload(const QString &filePath, const QString &fileName, - const QString &caption, const QString &date); + const QString &caption, const QString &date, const QString &tags); // Credential check via GET /upload (same Basic-auth gate the POST uses). // /health is not usable: nginx only proxies location /upload. From 3291ed67d55b77411cd096fbcbe0ab6f6282e2db Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sun, 18 Oct 2026 13:35:10 +0200 Subject: [PATCH 03/16] shorten the text for RSS feed link --- templates/grid.html | 2 +- templates/timeline.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/grid.html b/templates/grid.html index fc775e5..027b876 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -31,7 +31,7 @@ - RSS feed + Feed
- RSS feed + Feed + + + Manage — Photo Gallery + + + + + + + + +
+ +
Loading…
+ +
+
+ Trash + + +
+
+ +
+ +
+ 0 selected + + + + + +
+ +
+ +
+ +
+ + + + diff --git a/trash.go b/trash.go new file mode 100644 index 0000000..db9e6c0 --- /dev/null +++ b/trash.go @@ -0,0 +1,206 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// trashStamp is the timestamp moveToTrash appends: 20060102-150405. +var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`) + +// trashItem is one recoverable post in content/.trash/, grouping the image and +// its sidecar that were trashed together (same slug, same timestamp). +type trashItem struct { + ID string `json:"id"` // ".", the restore/empty handle + Slug string `json:"slug"` // original slug it would restore to + DeletedAt string `json:"deletedAt"` + Image string `json:"image"` // trash filename of the image, "" if only a sidecar + HasMeta bool `json:"hasMeta"` // whether a sidecar is present +} + +func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") } + +// parseTrashName splits a trash filename "." into its parts. +// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so +// the extension and the stamp peel off unambiguously from the right. +func parseTrashName(name string) (slug, stamp, ext string, ok bool) { + ext = filepath.Ext(name) + rest := strings.TrimSuffix(name, ext) // "." + i := strings.LastIndex(rest, ".") + if i < 0 { + return "", "", "", false + } + slug, stamp = rest[:i], rest[i+1:] + if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") { + return "", "", "", false + } + if !trashStamp.MatchString(stamp) { + return "", "", "", false + } + return slug, stamp, ext, true +} + +func isImageExt(ext string) bool { + for _, e := range contentExts { + if ext == e { + return true + } + } + return false +} + +// handleListTrash returns trashed items, newest first. +func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) { + entries, err := os.ReadDir(s.trashDir()) + if err != nil { + // No trash dir yet → empty list, not an error. + writeJSON(w, []trashItem{}) + return + } + + byID := make(map[string]*trashItem) + for _, e := range entries { + if e.IsDir() { + continue + } + slug, stamp, ext, ok := parseTrashName(e.Name()) + if !ok { + continue + } + id := slug + "." + stamp + it := byID[id] + if it == nil { + when := stamp + if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil { + when = t.Format("2006-01-02 15:04") + } + it = &trashItem{ID: id, Slug: slug, DeletedAt: when} + byID[id] = it + } + if ext == ".toml" { + it.HasMeta = true + } else if isImageExt(ext) { + it.Image = e.Name() + } + } + + items := make([]trashItem, 0, len(byID)) + for _, it := range byID { + items = append(items, *it) + } + // Newest first: the stamp sorts lexically in time order, and it is the tail + // of the ID. + sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID }) + writeJSON(w, items) +} + +// splitTrashID validates a "." handle and returns its parts. +func splitTrashID(id string) (slug, stamp string, ok bool) { + i := strings.LastIndex(id, ".") + if i < 0 { + return "", "", false + } + slug, stamp = id[:i], id[i+1:] + if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") { + return "", "", false + } + if !trashStamp.MatchString(stamp) { + return "", "", false + } + return slug, stamp, true +} + +// trashFiles returns the files in .trash belonging to one id (image + sidecar). +func (s *Server) trashFiles(slug, stamp string) []string { + var out []string + prefix := slug + "." + stamp + for _, ext := range append(append([]string{}, contentExts...), ".toml") { + p := filepath.Join(s.trashDir(), prefix+ext) + if _, err := os.Stat(p); err == nil { + out = append(out, p) + } + } + return out +} + +// handleRestore moves a trashed post back into content/, stripping the +// timestamp. It refuses to overwrite a live post with the same slug. +func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + slug, stamp, ok := splitTrashID(r.FormValue("id")) + if !ok { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + if _, live := s.resolveSlug(slug); live { + http.Error(w, "a post with that slug already exists", http.StatusConflict) + return + } + + files := s.trashFiles(slug, stamp) + if len(files) == 0 { + http.Error(w, "not in trash", http.StatusNotFound) + return + } + for _, src := range files { + name := filepath.Base(src) + trashPrefix := slug + "." + stamp // "." + // "..png" → ".png"; "..toml" → ".toml" + dstName := slug + strings.TrimPrefix(name, trashPrefix) + if err := os.Rename(src, filepath.Join(s.contentDir, dstName)); err != nil { + log.Printf("restore %s: %v", name, err) + http.Error(w, "could not restore", http.StatusInternalServerError) + return + } + } + s.rebuild() + w.WriteHeader(http.StatusOK) +} + +// handleEmptyTrash permanently deletes trashed items — specific ids (repeated +// `id` fields), or everything with `all=1`. This is the only hard delete. +func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if r.FormValue("all") == "1" { + if err := os.RemoveAll(s.trashDir()); err != nil { + log.Printf("empty trash: %v", err) + http.Error(w, "could not empty trash", http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"emptied": "all"}) + return + } + + removed := 0 + for _, id := range r.Form["id"] { + slug, stamp, ok := splitTrashID(id) + if !ok { + continue + } + for _, p := range s.trashFiles(slug, stamp) { + if err := os.Remove(p); err == nil { + removed++ + } + } + } + writeJSON(w, map[string]int{"removed": removed}) +} diff --git a/trash_test.go b/trash_test.go new file mode 100644 index 0000000..51a7888 --- /dev/null +++ b/trash_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" +) + +func listTrash(t *testing.T, s *Server) []trashItem { + t.Helper() + rec := httptest.NewRecorder() + s.handleListTrash(rec, httptest.NewRequest(http.MethodGet, "/api/trash", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("list trash: code=%d", rec.Code) + } + var items []trashItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("decode trash: %v", err) + } + return items +} + +func TestTrashListAndRestore(t *testing.T) { + s, content, _ := newTestServer(t) + + // Delete foo, then it should show up in the trash. + rec := httptest.NewRecorder() + s.handleDelete(rec, form(url.Values{"slug": {"foo"}})) + if rec.Code != http.StatusOK { + t.Fatalf("delete: %d", rec.Code) + } + + items := listTrash(t, s) + if len(items) != 1 { + t.Fatalf("trash has %d items, want 1", len(items)) + } + it := items[0] + if it.Slug != "foo" || !it.HasMeta || it.Image == "" { + t.Errorf("trash item wrong: %+v", it) + } + + // Restore it. + rec = httptest.NewRecorder() + s.handleRestore(rec, form(url.Values{"id": {it.ID}})) + if rec.Code != http.StatusOK { + t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body) + } + for _, p := range []string{"foo.png", "foo.toml"} { + if _, err := os.Stat(filepath.Join(content, p)); err != nil { + t.Errorf("restored %s missing: %v", p, err) + } + } + if items := listTrash(t, s); len(items) != 0 { + t.Errorf("trash not empty after restore: %+v", items) + } + // Caption survived the round trip. + if readMeta(t, content, "foo").Caption != "Foo" { + t.Error("restored sidecar lost its caption") + } +} + +func TestRestoreRefusesLiveCollision(t *testing.T) { + s, content, _ := newTestServer(t) + + rec := httptest.NewRecorder() + s.handleDelete(rec, form(url.Values{"slug": {"foo"}})) + if rec.Code != http.StatusOK { + t.Fatalf("delete: %d", rec.Code) + } + id := listTrash(t, s)[0].ID + + // A new live post now occupies the slug. + writePNG(t, filepath.Join(content, "foo.png")) + + rec = httptest.NewRecorder() + s.handleRestore(rec, form(url.Values{"id": {id}})) + if rec.Code != http.StatusConflict { + t.Errorf("restore over live post: code=%d, want 409", rec.Code) + } + // The trashed copy is left intact for a manual decision. + if len(listTrash(t, s)) != 1 { + t.Error("trashed item should remain after refused restore") + } +} + +func TestEmptyTrash(t *testing.T) { + s, _, _ := newTestServer(t) + + // Trash both posts. + rec := httptest.NewRecorder() + s.handleDelete(rec, form(url.Values{"slugs": {"foo,bar"}})) + if rec.Code != http.StatusOK { + t.Fatalf("delete: %d", rec.Code) + } + items := listTrash(t, s) + if len(items) != 2 { + t.Fatalf("trash has %d, want 2", len(items)) + } + + // Empty one by id. + rec = httptest.NewRecorder() + s.handleEmptyTrash(rec, form(url.Values{"id": {items[0].ID}})) + if rec.Code != http.StatusOK { + t.Fatalf("empty one: %d", rec.Code) + } + if len(listTrash(t, s)) != 1 { + t.Error("expected 1 item left after deleting one") + } + + // Empty all. + rec = httptest.NewRecorder() + s.handleEmptyTrash(rec, form(url.Values{"all": {"1"}})) + if rec.Code != http.StatusOK { + t.Fatalf("empty all: %d", rec.Code) + } + if len(listTrash(t, s)) != 0 { + t.Error("trash not empty after all") + } +} + +func TestSplitTrashIDRejectsJunk(t *testing.T) { + cases := map[string]bool{ + "foo.20260102-150405": true, + "foo.bar.20260102-150405": false, // slug "foo.bar" has a dot → rejected + "../x.20260102-150405": false, // traversal + "foo.2026": false, // malformed stamp + "foo": false, // no stamp + "": false, + } + for id, want := range cases { + if _, _, ok := splitTrashID(id); ok != want { + t.Errorf("splitTrashID(%q) = %v, want %v", id, ok, want) + } + } +} From 8fa1aa4cd4855d1374c73dbd5cc4e957e8fbf970 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sun, 18 Oct 2026 22:06:46 +0200 Subject: [PATCH 06/16] Add -default-view to choose timeline or grid as the site root The view named by -default-view (timeline, the default, or grid) is published at the site root; the other is served under /timeline/ or /grid/. viewBase and pageURL derive every page path, pager link and the Timeline<->Grid cross-link from the single setting, and Build removes both timeline/ and grid/ on each run so flipping the default never leaves a stale root behind. Reimplemented on trunk (which has albums and the /manage feature) rather than cherry-picked, since the original was written against an earlier base. Credit for the edit/delete and default-view ideas goes to Santhosh Raju, now noted in a README Contributors section. - main.go: -default-view flag, validated (fatal on anything but timeline/grid) - module.nix: services.photogallery.defaultView enum option - templates: timeline/grid/day/album/albums cross-links use TimelineURL/GridURL - tests: timeline-root, grid-root, and switch-cleanup --- CLAUDE.md | 6 +-- README.md | 7 +++ albums.go | 2 + default_view_test.go | 108 +++++++++++++++++++++++++++++++++++++ generator.go | 116 ++++++++++++++++++++++++---------------- main.go | 7 ++- manage_test.go | 2 +- module.nix | 7 +++ templates/album.html | 2 +- templates/albums.html | 2 +- templates/day.html | 2 +- templates/grid.html | 2 +- templates/timeline.html | 2 +- 13 files changed, 209 insertions(+), 56 deletions(-) create mode 100644 default_view_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dad8823..f5dffc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,9 +113,9 @@ contain spaces (`"berlin trip"`), and its URL slug is derived with `sanitiseSlug - Gruvbox dark hard colour scheme - Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url` - Manjari for Malayalam text (bio line), via `-ml-family` -- Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`): - - **Timeline is the site root** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this). - - **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header. +- 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`). Which view sits at the site root is chosen by `-default-view` (`timeline` or `grid`, default `timeline`; NixOS `services.photogallery.defaultView`): the chosen one owns `/` (`/`, `/page/2/`, …) and the other is published under `//`. `viewBase`/`pageURL` (`generator.go`) derive every page path, pager link and the Timeline↔Grid cross-links (`TimelineURL`/`GridURL` in the template data) from that one setting; `Build` removes both `timeline/` and `grid/` each run so a flipped default leaves no stale root behind. + - **Timeline** (the default root): 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** is the flat square grid. At `/grid/` by default, or at the root when `-default-view grid`. - 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 diff --git a/README.md b/README.md index b10bcab..f0643ec 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ A minimal static photo gallery with an Atom feed and an upload API. +## Contributors + +- **[Santhosh Raju](https://git.planet-express.in/fox)** — originated the ideas for the edit/delete ("manage") + capability and the configurable default view (`-default-view`, timeline or + grid as the site root). Both landed here as independent implementations + building on that work. + ## How it works 1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`). diff --git a/albums.go b/albums.go index 23ca4ed..dd2c3f9 100644 --- a/albums.go +++ b/albums.go @@ -108,6 +108,8 @@ func (g *Generator) baseData() map[string]any { "FontsURL": g.fontsURL, "SerifFamily": g.serifFamily, "MlFamily": g.mlFamily, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), } } diff --git a/default_view_test.go b/default_view_test.go new file mode 100644 index 0000000..924c0d5 --- /dev/null +++ b/default_view_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// buildWithView builds a two-photo site with the given default view and returns +// the output dir. +func buildWithView(t *testing.T, view string) string { + t.Helper() + content := t.TempDir() + output := t.TempDir() + writePNG(t, filepath.Join(content, "a.png")) + writePNG(t, filepath.Join(content, "b.png")) + + g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, view) + if err := g.Build(); err != nil { + t.Fatalf("build (%s): %v", view, err) + } + return output +} + +func exists(p string) bool { _, err := os.Stat(p); return err == nil } + +func TestDefaultViewTimeline(t *testing.T) { + out := buildWithView(t, viewTimeline) + + // Timeline owns the root; grid is the secondary under /grid/. + if !exists(filepath.Join(out, "index.html")) { + t.Error("root index.html missing") + } + if !exists(filepath.Join(out, "grid", "index.html")) { + t.Error("/grid/ should exist as the secondary view") + } + // No /timeline/ dir — timeline is at the root, not under its own name. + if exists(filepath.Join(out, "timeline")) { + t.Error("/timeline/ should not exist when timeline is the default") + } + + // The root (timeline) links to grid at /grid/. + root := readFile(t, filepath.Join(out, "index.html")) + if !strings.Contains(root, `href="/grid/"`) { + t.Error("timeline root should link to /grid/") + } +} + +func TestDefaultViewGrid(t *testing.T) { + out := buildWithView(t, viewGrid) + + // Grid owns the root; timeline is the secondary under /timeline/. + if !exists(filepath.Join(out, "index.html")) { + t.Error("root index.html missing") + } + if !exists(filepath.Join(out, "timeline", "index.html")) { + t.Error("/timeline/ should exist as the secondary view") + } + // No /grid/ dir — grid is at the root now. + if exists(filepath.Join(out, "grid")) { + t.Error("/grid/ should not exist when grid is the default") + } + + // The root (grid) links back to the timeline at /timeline/. + root := readFile(t, filepath.Join(out, "index.html")) + if !strings.Contains(root, `href="/timeline/"`) { + t.Error("grid root should link to /timeline/") + } +} + +// TestDefaultViewSwitchCleansUp confirms that flipping the default view does not +// leave the previous root's view directory behind. +func TestDefaultViewSwitchCleansUp(t *testing.T) { + content := t.TempDir() + output := t.TempDir() + writePNG(t, filepath.Join(content, "a.png")) + + // First build with grid as root → creates /timeline/. + g1 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewGrid) + if err := g1.Build(); err != nil { + t.Fatal(err) + } + if !exists(filepath.Join(output, "timeline")) { + t.Fatal("expected /timeline/ after grid-default build") + } + + // Rebuild with timeline as root → /timeline/ must be gone, /grid/ present. + g2 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewTimeline) + if err := g2.Build(); err != nil { + t.Fatal(err) + } + if exists(filepath.Join(output, "timeline")) { + t.Error("/timeline/ left over after switching default to timeline") + } + if !exists(filepath.Join(output, "grid", "index.html")) { + t.Error("/grid/ should exist after switching default to timeline") + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/generator.go b/generator.go index 04c97d9..e7456ce 100644 --- a/generator.go +++ b/generator.go @@ -27,6 +27,11 @@ const ( mediumMaxSize = 1600 defaultPageSize = 60 + // The two paginated views. The one chosen as -default-view is published at + // the site root, the other under //. + viewTimeline = "timeline" + viewGrid = "grid" + // Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a // 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely // reported to drop the preview entirely once the image passes ~300 KB, so @@ -80,15 +85,19 @@ type Generator struct { serifFamily string mlFamily string pageSize int + defaultView string mu sync.Mutex 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")) if pageSize < 1 { pageSize = defaultPageSize } + if defaultView != viewGrid { + defaultView = viewTimeline + } return &Generator{ contentDir: contentDir, outputDir: outputDir, @@ -101,10 +110,33 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f serifFamily: serifFamily, mlFamily: mlFamily, pageSize: pageSize, + defaultView: defaultView, tmpl: tmpl, } } +// viewBase returns the URL prefix a view is published under: "" for the default +// view, which owns the site root, and "/" 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) + "/" +} + +// timelineURL and gridURL are the roots of each view, for cross-links in the +// page headers. +func (g *Generator) timelineURL() string { return pageURL(g.viewBase(viewTimeline), 1) } +func (g *Generator) gridURL() string { return pageURL(g.viewBase(viewGrid), 1) } + func (g *Generator) initial() string { for _, r := range g.author { return strings.ToUpper(string(r)) @@ -124,19 +156,19 @@ func (g *Generator) Build() error { } // Drop fully-derived HTML trees before regenerating them, so a deleted - // post, a retag or a changed date leaves no orphaned page behind. These are - // cheap to rewrite (no up-to-date skipping applies to HTML), unlike the - // image renditions, which are pruned by slug just below. - for _, d := range []string{"photo", "day", "album", "page"} { + // post, a retag, a changed date or a flipped -default-view leaves no + // orphaned page behind. These are cheap to rewrite (no up-to-date skipping + // applies to HTML), unlike the image renditions, which are pruned by slug + // just below. Both "timeline" and "grid" are removed: whichever is the + // default owns the site root and has no dir of its own, so a dir by that + // name can only be a leftover from the opposite -default-view. + for _, d := range []string{"photo", "day", "album", "page", "timeline", "grid"} { if err := os.RemoveAll(filepath.Join(g.outputDir, d)); err != nil { return err } } - if err := os.RemoveAll(filepath.Join(g.outputDir, "grid", "page")); err != nil { - return err - } - for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} { + for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "album", "albums", "static"} { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { return err } @@ -467,17 +499,17 @@ func generateOG(src, dst string, maxDim int) error { return os.WriteFile(dst, buf.Bytes(), 0644) } -// renderGridPages writes the paginated square-grid view under public/grid/: -// grid/index.html and grid/page/N/index.html, chunking photos into pageSize -// slices for progressive pagination. The grid is the secondary view; the -// timeline is the site root. +// renderGridPages writes the paginated square-grid view. When grid is the +// -default-view it is the site root (index.html + page/N/); otherwise it lives +// under /grid/. Photos are chunked into pageSize slices for progressive +// pagination. func (g *Generator) renderGridPages(photos []Photo) error { total := len(photos) totalPages := (total + g.pageSize - 1) / g.pageSize if totalPages == 0 { totalPages = 1 } - gridDir := filepath.Join(g.outputDir, "grid") + base := g.viewBase(viewGrid) for i := 0; i < totalPages; i++ { start := i * g.pageSize @@ -487,27 +519,19 @@ func (g *Generator) renderGridPages(photos []Photo) error { } slice := photos[start:end] - dst := filepath.Join(gridDir, "index.html") - if i > 0 { - dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1)) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - dst = filepath.Join(dir, "index.html") + dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, 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 = "/grid/" - default: - prev = "/grid/page/" + strconv.Itoa(i) + "/" + if i > 0 { + prev = pageURL(base, i) } if i+1 < totalPages { - next = "/grid/page/" + strconv.Itoa(i+2) + "/" + next = pageURL(base, i+2) } data := map[string]any{ @@ -517,6 +541,8 @@ func (g *Generator) renderGridPages(photos []Photo) error { "TotalPages": totalPages, "PrevURL": prev, "NextURL": next, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, @@ -604,35 +630,29 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup { return pages } -// renderTimelinePages writes the day-grouped timeline as the site root: -// index.html and page/N/index.html. Photos are grouped by day, then packed +// renderTimelinePages writes the day-grouped timeline. When timeline is the +// -default-view (the default) it is the site root (index.html + page/N/); +// otherwise it lives under /timeline/. Photos are grouped by day, then packed // into pages of roughly pageSize without splitting a day across pages. func (g *Generator) renderTimelinePages(photos []Photo) error { pages := chunkDayGroups(groupByDay(photos), g.pageSize) totalPages := len(pages) + base := g.viewBase(viewTimeline) for i, groups := range pages { - dst := filepath.Join(g.outputDir, "index.html") - if i > 0 { - dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1)) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - dst = filepath.Join(dir, "index.html") + dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, 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 = "/" - default: - prev = "/page/" + strconv.Itoa(i) + "/" + if i > 0 { + prev = pageURL(base, i) } if i+1 < totalPages { - next = "/page/" + strconv.Itoa(i+2) + "/" + next = pageURL(base, i+2) } data := map[string]any{ @@ -642,6 +662,8 @@ func (g *Generator) renderTimelinePages(photos []Photo) error { "TotalPages": totalPages, "PrevURL": prev, "NextURL": next, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, @@ -673,6 +695,8 @@ func (g *Generator) renderDayPages(photos []Photo) error { "Count": grp.Count, "Photos": grp.Photos, "Total": len(photos), + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, diff --git a/main.go b/main.go index 5080078..819d9b1 100644 --- a/main.go +++ b/main.go @@ -21,15 +21,20 @@ func main() { 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)") htpasswd := flag.String("htpasswd", "", "path to an htpasswd file (bcrypt, `htpasswd -B`) guarding /upload; empty disables auth") + defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other is published under //") 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} { if err := os.MkdirAll(d, 0755); err != nil { 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 { log.Printf("initial build: %v", err) diff --git a/manage_test.go b/manage_test.go index bfa1b16..ddfe274 100644 --- a/manage_test.go +++ b/manage_test.go @@ -29,7 +29,7 @@ func newTestServer(t *testing.T) (*Server, string, string) { } writePNG(t, filepath.Join(content, "bar.png")) // no sidecar - g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60) + g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, viewTimeline) if err := g.Build(); err != nil { t.Fatalf("initial build: %v", err) } diff --git a/module.nix b/module.nix index dd5f855..f58c784 100644 --- a/module.nix +++ b/module.nix @@ -82,6 +82,12 @@ in { 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 is published under /timeline/ or /grid/."; + }; + htpasswdFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; @@ -156,6 +162,7 @@ in { "--serif-family" cfg.serifFamily "--ml-family" cfg.mlFamily "--page-size" (toString cfg.pageSize) + "--default-view" cfg.defaultView ] ++ lib.optionals (cfg.htpasswdFile != null) [ # %d is systemd's credentials directory; LoadCredential below drops # the htpasswd file there, readable only by this service. diff --git a/templates/album.html b/templates/album.html index 7f4ce95..26a3deb 100644 --- a/templates/album.html +++ b/templates/album.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

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

- Timeline + Timeline ← Albums diff --git a/templates/albums.html b/templates/albums.html index c434856..68c926d 100644 --- a/templates/albums.html +++ b/templates/albums.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

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

- ← Timeline + ← Timeline diff --git a/templates/day.html b/templates/day.html index 1089d06..1052fe2 100644 --- a/templates/day.html +++ b/templates/day.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

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

- ← Timeline + ← Timeline diff --git a/templates/grid.html b/templates/grid.html index 027b876..fc7eb57 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

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

- Timeline + Timeline Albums

{{.Handle}}

{{.Total}} posts

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

-
Grid + Grid Albums
0 selected - + - +
@@ -118,6 +118,7 @@
+ + diff --git a/templates/albums.html b/templates/albums.html index 68c926d..a613812 100644 --- a/templates/albums.html +++ b/templates/albums.html @@ -50,5 +50,6 @@
© 2000–{{.Year}} {{.Author}}. All rights reserved.
+ diff --git a/templates/day.html b/templates/day.html index 1052fe2..0672a26 100644 --- a/templates/day.html +++ b/templates/day.html @@ -46,5 +46,6 @@
© 2000–{{.Year}} {{.Author}}. All rights reserved.
+ diff --git a/templates/grid.html b/templates/grid.html index fc7eb57..ea9a0d6 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -103,5 +103,6 @@ io.observe(btn); })(); + diff --git a/templates/manage.html b/templates/manage.html index 7d87826..dc0fb9f 100644 --- a/templates/manage.html +++ b/templates/manage.html @@ -83,6 +83,7 @@ #toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; } #toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; } #empty { color: var(--fg4); font-family: var(--serif); padding: 40px 0; } + .row-hl { outline: 2px solid var(--aqua, #8ec07c); outline-offset: 4px; transition: outline-color .4s; } @@ -185,12 +186,27 @@ countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts'); emptyEl.style.display = photos.length ? 'none' : 'block'; refreshBulk(); + maybeFocusEdit(); } catch (err) { countEl.textContent = ''; flash('Could not load posts: ' + err.message, false); } } + // Deep link from a photo page's "Edit this": /manage?edit= scrolls to + // that row, focuses its caption and highlights it briefly. + function maybeFocusEdit() { + const slug = new URLSearchParams(location.search).get('edit'); + if (!slug) return; + const row = [...list.querySelectorAll('.row')].find(r => r.dataset.slug === slug); + if (!row) return; + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const cap = row.querySelector('.f-caption'); + if (cap) cap.focus(); + row.classList.add('row-hl'); + setTimeout(() => row.classList.remove('row-hl'), 2000); + } + async function post(url, body) { const res = await fetch(url, { method: 'POST', body }); if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status)); @@ -345,5 +361,6 @@ load(); + diff --git a/templates/photo.html b/templates/photo.html index de06795..0259415 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -35,7 +35,7 @@ {{if .Older}} {{end}} - +