Add Technical Kiwi website with Go, templ, and HTMX.

Single-page site with gallery by album and event, contact form over SMTP,
Docker dev/prod setup, and on-server image derivatives. Gallery photos stay
local (app/images/ is gitignored).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 23:57:59 +12:00
parent c21be097b0
commit 509e7ccb43
33 changed files with 2635 additions and 1 deletions

View File

@@ -0,0 +1,198 @@
package gallery
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Image is a gallery entry discovered on disk.
type Image struct {
RelPath string // e.g. templeoftechno/Anniversary/photo.jpg
Album string // top-level folder, e.g. templeoftechno
Collection string // event subfolder label, e.g. Anniversary
CollectionKey string // slug for filters
Filename string // basename
URL string
ThumbURL string
HeroURL string
Date time.Time
Year int
}
// List returns JPEG images from dir and subfolders (newest first).
// Skips generated thumbs/ and hero/ directories.
func List(dir string) ([]Image, error) {
var images []Image
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == thumbDirName || d.Name() == heroDirName {
return filepath.SkipDir
}
return nil
}
if !isJPEG(d.Name()) {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !safeRelPath(rel) {
return nil
}
album := albumFromRel(rel)
cKey, cLabel := collectionFromRel(rel)
date, year := imageDate(path, d.Name())
images = append(images, Image{
RelPath: rel,
Album: album,
Collection: cLabel,
CollectionKey: cKey,
Filename: d.Name(),
URL: "/images/" + URLPath(rel),
ThumbURL: derivativeURL(dir, thumbDirName, rel),
HeroURL: derivativeURL(dir, heroDirName, rel),
Date: date,
Year: year,
})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(images, func(i, j int) bool {
if images[i].Date.Equal(images[j].Date) {
return images[i].RelPath < images[j].RelPath
}
return images[i].Date.After(images[j].Date)
})
return images, nil
}
// Collections returns sorted collection keys for an album (empty album = all).
func Collections(images []Image, album string) []string {
seen := make(map[string]string)
var keys []string
for _, img := range images {
if img.CollectionKey == "" {
continue
}
if album != "" && img.Album != album {
continue
}
if _, ok := seen[img.CollectionKey]; ok {
continue
}
seen[img.CollectionKey] = img.Collection
keys = append(keys, img.CollectionKey)
}
sort.Slice(keys, func(i, j int) bool {
return seen[keys[i]] < seen[keys[j]]
})
return keys
}
// Albums returns sorted unique album folder names.
func Albums(images []Image) []string {
seen := make(map[string]struct{})
var albums []string
for _, img := range images {
if img.Album == "" {
continue
}
if _, ok := seen[img.Album]; ok {
continue
}
seen[img.Album] = struct{}{}
albums = append(albums, img.Album)
}
sort.Strings(albums)
return albums
}
// AlbumLabel returns a display name for an album folder.
func AlbumLabel(album string) string {
labels := map[string]string{
"escaperoom": "Escape room",
"ledlyra": "LED Lyra",
"misc": "Misc",
"portal": "Portal",
"templeoftechno": "Temple of techno",
"connectionmachine": "Connection machine",
}
if label, ok := labels[album]; ok {
return label
}
return strings.ReplaceAll(album, "-", " ")
}
func albumFromRel(rel string) string {
if i := strings.Index(rel, "/"); i >= 0 {
return rel[:i]
}
return ""
}
// SafeRelPath reports whether rel is safe to serve (exported for handlers).
func SafeRelPath(rel string) bool {
return safeRelPath(rel)
}
func safeRelPath(rel string) bool {
if rel == "" || strings.Contains(rel, "..") {
return false
}
return true
}
func derivativeURL(imagesDir, subdir, rel string) string {
if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(rel))); err == nil {
return "/images/" + subdir + "/" + URLPath(rel)
}
return ""
}
func imageDate(fullPath, filename string) (time.Time, int) {
if t, y := parseDate(filename); !t.IsZero() {
return t, y
}
info, err := os.Stat(fullPath)
if err != nil {
return time.Time{}, 0
}
mod := info.ModTime()
return mod, mod.Year()
}
func parseDate(filename string) (time.Time, int) {
base := strings.TrimSuffix(filename, filepath.Ext(filename))
// IMG_YYYYMMDD_HHMMSS.jpg
if strings.HasPrefix(base, "IMG_") && len(base) >= 12 {
raw := strings.TrimPrefix(base, "IMG_")
if t, err := time.Parse("20060102", raw[:8]); err == nil {
return t, t.Year()
}
}
// 20220723_231556.jpg
if len(base) >= 15 && base[8] == '_' {
if t, err := time.Parse("20060102_150405", base[:15]); err == nil {
return t, t.Year()
}
}
return time.Time{}, 0
}

View File

@@ -0,0 +1,57 @@
package gallery
import (
"os"
"path/filepath"
"testing"
)
func TestAlbumFromRel(t *testing.T) {
if albumFromRel("portal/IMG_20241012_115041.jpg") != "portal" {
t.Fatal("expected portal album")
}
if albumFromRel("IMG_20241012_115041.jpg") != "" {
t.Fatal("expected empty album for root file")
}
}
func TestSafeRelPath(t *testing.T) {
if !safeRelPath("portal/foo.jpg") {
t.Fatal("valid path rejected")
}
if safeRelPath("../etc/passwd") {
t.Fatal("traversal allowed")
}
}
func TestList_subfolders(t *testing.T) {
dir := t.TempDir()
album := filepath.Join(dir, "portal")
if err := os.MkdirAll(album, 0o755); err != nil {
t.Fatal(err)
}
name := "IMG_20240101_120000.jpg"
if err := os.WriteFile(filepath.Join(album, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
images, err := List(dir)
if err != nil {
t.Fatal(err)
}
if len(images) != 1 || images[0].Album != "portal" || images[0].RelPath != "portal/"+name {
t.Fatalf("got %+v", images)
}
}
func TestCollections_nested(t *testing.T) {
images := []Image{
{Album: "templeoftechno", CollectionKey: "anniversary", Collection: "Anniversary"},
{Album: "templeoftechno", CollectionKey: "groovatory", Collection: "Groovatory"},
{Album: "portal", CollectionKey: "x", Collection: "X"},
}
keys := Collections(images, "templeoftechno")
if len(keys) != 2 {
t.Fatalf("got %v", keys)
}
}

View File

@@ -0,0 +1,34 @@
package gallery
import (
"os"
"strings"
)
const defaultHeroRel = "connectionmachine/20220723_231556.jpg"
// HeroRelPath returns the configured hero image path relative to images/.
func HeroRelPath() string {
if p := strings.TrimSpace(os.Getenv("HERO_IMAGE")); p != "" {
return filepathToSlash(p)
}
return defaultHeroRel
}
// SelectHero picks the homepage hero from the gallery list.
func SelectHero(images []Image) (Image, bool) {
want := HeroRelPath()
for _, img := range images {
if img.RelPath == want {
return img, true
}
}
if len(images) > 0 {
return images[0], true
}
return Image{}, false
}
func filepathToSlash(p string) string {
return strings.ReplaceAll(p, "\\", "/")
}

View File

@@ -0,0 +1,22 @@
package gallery
import "testing"
func TestSelectHero_configured(t *testing.T) {
t.Setenv("HERO_IMAGE", "connectionmachine/20220723_231556.jpg")
images := []Image{
{RelPath: "templeoftechno/IMG_20250817_020719.jpg"},
{RelPath: "connectionmachine/20220723_231556.jpg", HeroURL: "/images/hero/connectionmachine/20220723_231556.jpg"},
}
hero, ok := SelectHero(images)
if !ok || hero.RelPath != "connectionmachine/20220723_231556.jpg" {
t.Fatalf("got %+v ok=%v", hero, ok)
}
}
func TestParseDate_20220723(t *testing.T) {
tm, year := parseDate("20220723_231556.jpg")
if tm.IsZero() || year != 2022 {
t.Fatalf("got %v year=%d", tm, year)
}
}

View File

@@ -0,0 +1,59 @@
package gallery
import (
"net/url"
"regexp"
"strings"
)
var zipExportSuffix = regexp.MustCompile(`-\d{8}T\d{6}Z-1-\d+$`)
// URLPath escapes a relative image path for use in URL paths.
func URLPath(rel string) string {
parts := strings.Split(rel, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
func collectionFromRel(rel string) (key, label string) {
parts := strings.Split(rel, "/")
if len(parts) <= 2 {
return "", ""
}
inner := strings.TrimSpace(parts[len(parts)-2])
label = cleanCollectionName(inner)
if label == "" {
return "", ""
}
return collectionKey(label), label
}
func cleanCollectionName(name string) string {
name = strings.TrimSpace(name)
name = zipExportSuffix.ReplaceAllString(name, "")
return strings.TrimSpace(name)
}
func collectionKey(label string) string {
key := strings.ToLower(label)
key = strings.ReplaceAll(key, " ", "-")
key = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return -1
}, key)
return key
}
// CollectionLabel returns display name for a collection filter key.
func CollectionLabel(key string, images []Image) string {
for _, img := range images {
if img.CollectionKey == key {
return img.Collection
}
}
return key
}

View File

@@ -0,0 +1,22 @@
package gallery
import "testing"
func TestCollectionFromRel(t *testing.T) {
rel := "templeoftechno/Anniversary-20251105T063538Z-1-001/Anniversary/photo.jpg"
key, label := collectionFromRel(rel)
if label != "Anniversary" {
t.Fatalf("label=%q", label)
}
if key != "anniversary" {
t.Fatalf("key=%q", key)
}
}
func TestURLPath_spaces(t *testing.T) {
got := URLPath("templeoftechno/The Groovatory/foo.jpg")
want := "templeoftechno/The%20Groovatory/foo.jpg"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}

View File

@@ -0,0 +1,151 @@
package gallery
import (
"image"
"image/jpeg"
"io/fs"
"os"
"path/filepath"
"strings"
"golang.org/x/image/draw"
)
const (
gridThumbMaxEdge = 320
heroThumbMaxEdge = 640
gridThumbQuality = 75
heroThumbQuality = 78
thumbDirName = "thumbs"
heroDirName = "hero"
)
// EnsureThumbnails creates grid and hero JPEG derivatives mirroring album folders.
func EnsureThumbnails(imagesDir string) error {
if err := os.MkdirAll(filepath.Join(imagesDir, thumbDirName), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(imagesDir, heroDirName), 0o755); err != nil {
return err
}
return filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == thumbDirName || d.Name() == heroDirName {
return filepath.SkipDir
}
return nil
}
if !isJPEG(d.Name()) {
return nil
}
rel, err := filepath.Rel(imagesDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !safeRelPath(rel) {
return nil
}
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel))
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
return err
}
if err := writeThumb(path, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
return err
}
return writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality)
})
}
// EnsurePriority generates thumbnails for one image before first page load.
func EnsurePriority(imagesDir, relPath string) error {
if !safeRelPath(relPath) || !isJPEG(filepath.Base(relPath)) {
return nil
}
src := filepath.Join(imagesDir, filepath.FromSlash(relPath))
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(relPath))
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(relPath))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
return err
}
if err := writeThumb(src, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil {
return err
}
return writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality)
}
func writeThumb(src, dst string, maxEdge, quality int) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if dstInfo, err := os.Stat(dst); err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) {
return nil
}
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
img, err := jpeg.Decode(f)
if err != nil {
return err
}
thumb := resizeThumb(img, maxEdge)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, thumb, &jpeg.Options{Quality: quality})
}
func resizeThumb(src image.Image, maxEdge int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return src
}
scale := float64(maxEdge) / float64(w)
if h > w {
scale = float64(maxEdge) / float64(h)
}
nw := int(float64(w) * scale)
nh := int(float64(h) * scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
if nw >= w && nh >= h {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}
func isJPEG(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".jpg" || ext == ".jpeg"
}