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:
198
app/internal/gallery/gallery.go
Normal file
198
app/internal/gallery/gallery.go
Normal 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
|
||||
}
|
||||
57
app/internal/gallery/gallery_test.go
Normal file
57
app/internal/gallery/gallery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
34
app/internal/gallery/hero.go
Normal file
34
app/internal/gallery/hero.go
Normal 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, "\\", "/")
|
||||
}
|
||||
22
app/internal/gallery/hero_test.go
Normal file
22
app/internal/gallery/hero_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
app/internal/gallery/path.go
Normal file
59
app/internal/gallery/path.go
Normal 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
|
||||
}
|
||||
22
app/internal/gallery/path_test.go
Normal file
22
app/internal/gallery/path_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
151
app/internal/gallery/thumbs.go
Normal file
151
app/internal/gallery/thumbs.go
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user