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,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"
}