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

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# SMTP relay for the contact form (all three required to enable the form)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=website@technical.kiwi
SMTP_TO=hello@technical.kiwi
# TLS mode: auto (STARTTLS on 587), tls (465), plain (25/local relay)
SMTP_TLS=auto
# HTTP listen address
ADDR=:8080
# Homepage hero (path under app/images/)
HERO_IMAGE=connectionmachine/20220723_231556.jpg
# Host Go caches mounted into the dev container (make sets ~/go/pkg/mod and ~/.cache/go-build if unset)
# GOMODCACHE=/home/you/go/pkg/mod
# GOCACHE=/home/you/.cache/go-build

16
.gitignore vendored
View File

@@ -21,3 +21,19 @@
# Go workspace file # Go workspace file
go.work go.work
# Air / local build artifacts in app/
/app/tmp/
/app/bin/
# Gallery photos and generated derivatives (not in git)
/app/images/**
!/app/images/.gitkeep
/bin/
# templ generated (regenerate with `templ generate`)
/app/templates/*_templ.go
# Local env (copy .env.example)
.env

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
ARG GO_VERSION=1.25
FROM golang:${GO_VERSION}-bookworm AS builder
RUN go install github.com/a-h/templ/cmd/templ@latest
WORKDIR /build
COPY app/go.mod app/go.sum ./
RUN go mod download
COPY app/ ./
RUN templ generate && CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /server ./server
COPY app/static ./static
COPY app/images ./images
RUN ./server -thumbs
EXPOSE 8080
ENV ADDR=:8080
CMD ["./server"]

18
Dockerfile.dev Normal file
View File

@@ -0,0 +1,18 @@
ARG GO_VERSION=1.25
FROM golang:${GO_VERSION}-bookworm
RUN go install github.com/a-h/templ/cmd/templ@latest \
&& go install github.com/air-verse/air@latest
WORKDIR /app
COPY app/go.mod app/go.sum ./
RUN go mod download
COPY app/.air.toml app/dev.sh ./
RUN chmod +x dev.sh
EXPOSE 8080 7331
ENV ADDR=:8080
CMD ["./dev.sh"]

36
Makefile Normal file
View File

@@ -0,0 +1,36 @@
COMPOSE ?= docker compose
# Compose does not support nested ${VAR:-${HOME}/...}; set defaults here (override in .env).
-include .env
export GOMODCACHE ?= $(HOME)/go/pkg/mod
export GOCACHE ?= $(HOME)/.cache/go-build
# One-off commands in the dev image (same caches and ./app mount as `make dev`)
DEV_RUN := $(COMPOSE) run --rm --no-deps dev
.PHONY: dev build up down generate tidy logs
.DEFAULT_GOAL := dev
dev:
@test -f .env || cp -n .env.example .env
$(COMPOSE) up --build dev
build:
$(COMPOSE) build website
up:
@test -f .env || cp -n .env.example .env
$(COMPOSE) up --build website
down:
$(COMPOSE) down
generate:
$(DEV_RUN) sh -c "templ generate"
tidy:
$(DEV_RUN) go mod tidy
logs:
$(COMPOSE) logs -f dev

View File

@@ -1,2 +1,86 @@
# website # Technical Kiwi Limited — Website
A small business site built with **Go**, [**templ**](https://templ.guide/) (server-side HTML templates), and [**HTMX**](https://htmx.org/) for gallery interactions.
## Requirements
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
## Quick start
```bash
cp .env.example .env # optional SMTP settings
make dev # live reload → http://localhost:7331
```
## Make targets
| Target | Description |
|--------|-------------|
| `make dev` | Dev with Air + templ browser live reload (default) |
| `make build` | Build production `website` image |
| `make up` | Run production `website` service |
| `make down` | Stop compose services |
| `make generate` | Run `templ generate` in dev container |
| `make tidy` | Run `go mod tidy` in dev container |
| `make logs` | Follow dev container logs |
## Features
- Single-page layout: hero, services, gallery, contact
- Gallery loads via HTMX (`hx-get="/gallery"`)
- Year filters without full page reload
- Lightbox modal with previous/next navigation
- Contact form with SMTP relay (HTMX submit, no page reload)
- Serves photos from `app/images/`
## Contact form (SMTP)
Set relay details in `.env`:
| Variable | Description |
|----------|-------------|
| `SMTP_HOST` | Relay hostname (required) |
| `SMTP_PORT` | Port (default `587`) |
| `SMTP_USER` / `SMTP_PASSWORD` | Auth if your relay requires it |
| `SMTP_FROM` | Envelope/header From address (required) |
| `SMTP_TO` | Where contact messages are delivered (required) |
| `SMTP_TLS` | `auto`, `tls` (465), or `plain` (25/local relay) |
If SMTP is not configured, the page shows a mailto fallback instead of the form.
## Dev container
`make dev` mounts `./app` and your host Go caches (`~/go/pkg/mod`, `~/.cache/go-build` by default). Override with `GOMODCACHE` / `GOCACHE` in `.env`.
**Use [http://localhost:7331](http://localhost:7331)** in the browser — the templ proxy injects live reload on `.templ`, `.go`, and `.css` changes. Port 8080 hits the app directly without auto-reload.
Only run **`dev`** or **`website`** at a time if you map both to the same host ports.
Production `website` joins the external `caddy` Docker network for reverse proxy labels.
## Project layout
```
app/
cmd/server/ HTTP server entrypoint
internal/gallery/ Scan and sort images from disk
internal/contact/ Form validation
internal/mail/ SMTP relay
internal/handlers/ Routes and HTMX partials
templates/ templ components (.templ → generated Go)
static/ CSS
images/ Gallery photos (served at /images/)
Dockerfile Production image
Dockerfile.dev Dev image (Go + templ + Air)
docker-compose.yaml
.env.example
```
## Gallery images
On startup the server builds JPEG thumbnails in `app/images/thumbs/` (max 480px edge) for the grid and hero. The lightbox still loads full-resolution originals. Thumbnails are gitignored and regenerated when source photos change.
## Deploy notes
First request after deploy may take a moment while thumbnails are generated if they are not baked into the image yet.

18
app/.air.toml Normal file
View File

@@ -0,0 +1,18 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "templ generate --notify-proxy && go build -o ./tmp/main ./cmd/server"
entrypoint = ["./tmp/main"]
delay = 300
exclude_dir = ["tmp", "bin", "images", "vendor", "testdata"]
exclude_regex = ["_templ.go"]
include_ext = ["go", "css"]
send_interrupt = true
stop_on_error = true
[log]
time = true
[misc]
clean_on_exit = true

68
app/cmd/server/main.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"flag"
"log"
"net/http"
"os"
"path/filepath"
"technical.kiwi/website/internal/gallery"
"technical.kiwi/website/internal/handlers"
"technical.kiwi/website/internal/mail"
"technical.kiwi/website/internal/middleware"
)
func main() {
thumbsOnly := flag.Bool("thumbs", false, "generate gallery thumbnails and exit")
flag.Parse()
root, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
imagesDir := filepath.Join(root, "images")
staticDir := filepath.Join(root, "static")
if *thumbsOnly {
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
log.Fatal(err)
}
log.Print("thumbnails generated")
return
}
var mailCfg *mail.Config
if mail.Enabled() {
cfg, err := mail.Load()
if err != nil {
log.Fatalf("smtp config: %v", err)
}
mailCfg = &cfg
log.Printf("contact form: smtp relay %s:%d → %s", cfg.Host, cfg.Port, cfg.To)
} else {
log.Print("contact form: disabled (set SMTP_HOST, SMTP_FROM, SMTP_TO to enable)")
}
srv, err := handlers.New(imagesDir, staticDir, mailCfg)
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
addr := envOr("ADDR", ":8080")
log.Printf("Technical Kiwi website listening on %s", addr)
if err := http.ListenAndServe(addr, middleware.Gzip(mux)); err != nil {
log.Fatal(err)
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

11
app/dev.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
# Browser: http://localhost:7331 (proxy injects auto-reload)
# App: http://127.0.0.1:8080 (internal, via proxy)
exec templ generate --watch \
--proxy="http://127.0.0.1:8080" \
--proxybind="0.0.0.0" \
--proxyport="7331" \
--open-browser=false \
--cmd="air -c .air.toml"

7
app/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module technical.kiwi/website
go 1.25.0
require github.com/a-h/templ v0.3.1020
require golang.org/x/image v0.41.0

6
app/go.sum Normal file
View File

@@ -0,0 +1,6 @@
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=

0
app/images/.gitkeep Normal file
View File

View File

@@ -0,0 +1,65 @@
package contact
import (
"net/mail"
"strings"
"unicode/utf8"
)
const (
maxNameLen = 120
maxEmailLen = 254
maxMessageLen = 8000
minMessageLen = 10
)
// Submission is validated contact form input.
type Submission struct {
Name string
Email string
Message string
}
// Errors maps field names to user-facing messages.
type Errors map[string]string
func (e Errors) Any() bool {
return len(e) > 0
}
// Parse reads and validates a contact form POST.
func Parse(name, email, message string) (Submission, Errors) {
errs := make(Errors)
name = strings.TrimSpace(name)
email = strings.TrimSpace(email)
message = strings.TrimSpace(message)
if name == "" {
errs["name"] = "Name is required."
} else if utf8.RuneCountInString(name) > maxNameLen {
errs["name"] = "Name is too long."
}
if email == "" {
errs["email"] = "Email is required."
} else if utf8.RuneCountInString(email) > maxEmailLen {
errs["email"] = "Email is too long."
} else if _, err := mail.ParseAddress(email); err != nil {
errs["email"] = "Enter a valid email address."
}
if message == "" {
errs["message"] = "Message is required."
} else if utf8.RuneCountInString(message) < minMessageLen {
errs["message"] = "Message must be at least 10 characters."
} else if utf8.RuneCountInString(message) > maxMessageLen {
errs["message"] = "Message is too long."
}
if errs.Any() {
return Submission{}, errs
}
return Submission{Name: name, Email: email, Message: message}, nil
}

View File

@@ -0,0 +1,27 @@
package contact
import "testing"
func TestParse_valid(t *testing.T) {
sub, errs := Parse("Jimmy", "jim@example.com", "Hello there, this is a test message.")
if errs.Any() {
t.Fatalf("unexpected errors: %v", errs)
}
if sub.Name != "Jimmy" || sub.Email != "jim@example.com" {
t.Fatalf("got %+v", sub)
}
}
func TestParse_shortMessage(t *testing.T) {
_, errs := Parse("Jimmy", "jim@example.com", "short")
if !errs.Any() {
t.Fatal("expected validation error")
}
}
func TestParse_invalidEmail(t *testing.T) {
_, errs := Parse("Jimmy", "not-an-email", "This message is long enough to pass.")
if errs["email"] == "" {
t.Fatal("expected email error")
}
}

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

View File

@@ -0,0 +1,183 @@
package handlers
import (
"log"
"net/http"
"strings"
"time"
"technical.kiwi/website/internal/contact"
"technical.kiwi/website/internal/gallery"
"technical.kiwi/website/internal/mail"
"technical.kiwi/website/internal/middleware"
"technical.kiwi/website/templates"
)
// Server holds shared state for HTTP handlers.
type Server struct {
ImagesDir string
StaticDir string
Images []gallery.Image
Hero gallery.Image
HasHero bool
Mail *mail.Config
ContactEnabled bool
}
// New builds a Server after loading the image gallery.
func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
images, err := gallery.List(imagesDir)
if err != nil {
return nil, err
}
hero, hasHero := gallery.SelectHero(images)
if hasHero {
if err := gallery.EnsurePriority(imagesDir, hero.RelPath); err != nil {
log.Printf("hero image: %v", err)
} else {
images, err = gallery.List(imagesDir)
if err != nil {
return nil, err
}
hero, hasHero = gallery.SelectHero(images)
}
log.Printf("hero image: %s", hero.RelPath)
}
srv := &Server{
ImagesDir: imagesDir,
StaticDir: staticDir,
Images: images,
Hero: hero,
HasHero: hasHero,
Mail: mailCfg,
ContactEnabled: mailCfg != nil,
}
go func() {
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
log.Printf("gallery thumbnails: %v", err)
return
}
log.Print("gallery thumbnails: ready")
}()
return srv, nil
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
templates.Home(s.Images, s.Hero, s.HasHero, s.ContactEnabled).Render(r.Context(), w)
}
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if strings.TrimSpace(r.FormValue("website")) != "" {
templates.ContactSuccess().Render(r.Context(), w)
return
}
sub, errs := contact.Parse(
r.FormValue("name"),
r.FormValue("email"),
r.FormValue("message"),
)
if errs.Any() {
templates.ContactValidationAlert(errs).Render(r.Context(), w)
return
}
if s.Mail == nil {
templates.ContactSendError().Render(r.Context(), w)
return
}
if err := s.Mail.ContactEmail(sub.Name, sub.Email, sub.Message); err != nil {
log.Printf("contact mail: %v", err)
templates.ContactSendError().Render(r.Context(), w)
return
}
templates.ContactSuccess().Render(r.Context(), w)
}
func (s *Server) galleryPartial(w http.ResponseWriter, r *http.Request) {
album := r.URL.Query().Get("album")
collection := r.URL.Query().Get("collection")
filtered := s.filterImages(album, collection)
templates.GalleryGrid(filtered).Render(r.Context(), w)
}
func (s *Server) imageModal(w http.ResponseWriter, r *http.Request) {
rel := r.PathValue("path")
if !gallery.SafeRelPath(rel) {
http.NotFound(w, r)
return
}
img, ok := s.findImage(rel)
if !ok {
http.NotFound(w, r)
return
}
idx := s.indexOf(rel)
prev, next := "", ""
if idx > 0 {
prev = s.Images[idx-1].RelPath
}
if idx >= 0 && idx < len(s.Images)-1 {
next = s.Images[idx+1].RelPath
}
templates.ImageModal(img, prev, next).Render(r.Context(), w)
}
func (s *Server) filterImages(album, collection string) []gallery.Image {
if album == "" && collection == "" {
return s.Images
}
var out []gallery.Image
for _, img := range s.Images {
if album != "" && img.Album != album {
continue
}
if collection != "" && img.CollectionKey != collection {
continue
}
out = append(out, img)
}
return out
}
func (s *Server) findImage(rel string) (gallery.Image, bool) {
for _, img := range s.Images {
if img.RelPath == rel {
return img, true
}
}
return gallery.Image{}, false
}
func (s *Server) indexOf(rel string) int {
for i, img := range s.Images {
if img.RelPath == rel {
return i
}
}
return -1
}
// RegisterRoutes attaches handlers to mux.
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("POST /contact", s.contact)
mux.HandleFunc("GET /gallery", s.galleryPartial)
mux.HandleFunc("GET /gallery/{path...}", s.imageModal)
staticHandler := middleware.CacheStatic(s.StaticDir, 7*24*time.Hour)
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler))
mux.Handle("GET /images/", http.StripPrefix("/images/", middleware.CacheImages(s.ImagesDir)))
}

View File

@@ -0,0 +1,71 @@
package mail
import (
"fmt"
"os"
"strconv"
)
// Config holds SMTP relay settings from the environment.
type Config struct {
Host string
Port int
User string
Password string
From string
To string
// TLS: "auto" (STARTTLS on 587, plain on 25), "tls" (implicit TLS, e.g. 465), "plain"
TLS string
}
// Load reads SMTP settings from environment variables.
//
// Required: SMTP_HOST, SMTP_FROM, SMTP_TO
// Optional: SMTP_PORT (587), SMTP_USER, SMTP_PASSWORD, SMTP_TLS (auto)
func Load() (Config, error) {
host := os.Getenv("SMTP_HOST")
if host == "" {
return Config{}, fmt.Errorf("SMTP_HOST is required")
}
from := os.Getenv("SMTP_FROM")
if from == "" {
return Config{}, fmt.Errorf("SMTP_FROM is required")
}
to := os.Getenv("SMTP_TO")
if to == "" {
return Config{}, fmt.Errorf("SMTP_TO is required")
}
port := 587
if p := os.Getenv("SMTP_PORT"); p != "" {
n, err := strconv.Atoi(p)
if err != nil {
return Config{}, fmt.Errorf("SMTP_PORT: %w", err)
}
port = n
}
tls := os.Getenv("SMTP_TLS")
if tls == "" {
tls = "auto"
}
return Config{
Host: host,
Port: port,
User: os.Getenv("SMTP_USER"),
Password: os.Getenv("SMTP_PASSWORD"),
From: from,
To: to,
TLS: tls,
}, nil
}
// Enabled reports whether relay settings are present.
func Enabled() bool {
return os.Getenv("SMTP_HOST") != "" &&
os.Getenv("SMTP_FROM") != "" &&
os.Getenv("SMTP_TO") != ""
}

163
app/internal/mail/send.go Normal file
View File

@@ -0,0 +1,163 @@
package mail
import (
"bytes"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
)
// ContactEmail sends a contact form message via the configured SMTP relay.
func (c Config) ContactEmail(name, replyTo, message string) error {
subject := fmt.Sprintf("Contact form: %s", name)
var body bytes.Buffer
body.WriteString(fmt.Sprintf("Name: %s\n", name))
body.WriteString(fmt.Sprintf("Email: %s\n\n", replyTo))
body.WriteString(message)
return c.send(subject, body.String(), replyTo)
}
func (c Config) send(subject, body, replyTo string) error {
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", c.From))
msg.WriteString(fmt.Sprintf("To: %s\r\n", c.To))
if replyTo != "" {
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", replyTo))
}
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(body)
raw := msg.Bytes()
from := extractAddr(c.From)
var auth smtp.Auth
if c.User != "" {
auth = smtp.PlainAuth("", c.User, c.Password, c.Host)
}
switch c.tlsMode() {
case "plain":
return smtp.SendMail(addr, auth, from, []string{c.To}, raw)
case "tls":
return c.sendTLS(addr, auth, from, raw)
default:
return c.sendStartTLS(addr, auth, from, raw)
}
}
func (c Config) tlsMode() string {
switch strings.ToLower(c.TLS) {
case "plain", "tls":
return strings.ToLower(c.TLS)
}
if c.Port == 465 {
return "tls"
}
if c.Port == 25 {
return "plain"
}
return "starttls"
}
func (c Config) sendStartTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
conn, err := net.Dial("tcp", addr)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, c.Host)
if err != nil {
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
return err
}
}
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
if err := client.Auth(auth); err != nil {
return err
}
}
}
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(c.To); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write(raw); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}
func (c Config) sendTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: c.Host})
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, c.Host)
if err != nil {
return err
}
defer client.Close()
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
if err := client.Auth(auth); err != nil {
return err
}
}
}
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(c.To); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write(raw); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}
func extractAddr(from string) string {
if i := strings.Index(from, "<"); i >= 0 {
if j := strings.Index(from[i:], ">"); j > 0 {
return strings.TrimSpace(from[i+1 : i+j])
}
}
return strings.TrimSpace(from)
}

View File

@@ -0,0 +1,61 @@
package middleware
import (
"compress/gzip"
"io"
"net/http"
"strconv"
"strings"
"time"
)
type gzipResponseWriter struct {
http.ResponseWriter
io.Writer
}
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}
// Gzip compresses responses when the client accepts it.
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Vary", "Accept-Encoding")
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
})
}
// CacheStatic wraps a file server with long-lived cache headers.
func CacheStatic(dir string, maxAge time.Duration) http.Handler {
fs := http.FileServer(http.Dir(dir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age="+formatMaxAge(maxAge)+", immutable")
fs.ServeHTTP(w, r)
})
}
// CacheImages wraps image serving; thumbs cache longer than originals.
func CacheImages(dir string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, "/")
if strings.HasPrefix(rel, "thumbs/") || strings.HasPrefix(rel, "hero/") {
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=86400")
}
http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
})
}
func formatMaxAge(d time.Duration) string {
return strconv.Itoa(int(d.Seconds()))
}

1
app/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

777
app/static/style.css Normal file
View File

@@ -0,0 +1,777 @@
:root {
--bg: #14100c;
--bg-elevated: #1f1812;
--surface: #2a2219;
--text: #f5efe6;
--text-muted: #b5a090;
--accent: #c47b3a;
--accent-soft: rgba(196, 123, 58, 0.2);
--border: rgba(245, 239, 230, 0.1);
--radius: 12px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
color-scheme: dark;
}
body {
margin: 0;
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
display: block;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1.25rem clamp(1.25rem, 4vw, 3rem);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: rgba(20, 16, 12, 0.92);
backdrop-filter: blur(10px);
z-index: 10;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text);
text-decoration: none;
font-weight: 600;
}
.logo:hover {
text-decoration: none;
}
.logo-mark {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #e8c9a0, var(--accent) 55%, #6b3d1f);
color: #14100c;
display: grid;
place-items: center;
font-family: var(--mono);
font-weight: 700;
font-size: 1rem;
box-shadow: 0 0 24px var(--accent-soft);
}
.site-nav {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.site-nav a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.95rem;
}
.site-nav a:hover {
color: var(--text);
text-decoration: none;
}
main {
padding-bottom: 4rem;
}
.hero {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 2.5rem;
align-items: center;
padding: clamp(2.5rem, 6vw, 5rem) clamp(1.25rem, 4vw, 3rem);
max-width: 1200px;
margin: 0 auto;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
}
}
.eyebrow {
font-family: var(--mono);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 0.75rem;
}
.hero h1 {
font-size: clamp(2rem, 4.5vw, 3.25rem);
line-height: 1.15;
margin: 0 0 1rem;
font-weight: 700;
}
.lead {
color: var(--text-muted);
font-size: 1.1rem;
max-width: 38ch;
margin: 0 0 1.75rem;
}
.hero-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
padding: 0.7rem 1.25rem;
border-radius: 999px;
font-weight: 600;
text-decoration: none;
border: 1px solid transparent;
cursor: pointer;
}
.btn-primary {
background: var(--accent);
color: #14100c;
}
.btn-primary:hover {
filter: brightness(1.08);
text-decoration: none;
}
.btn-ghost {
border-color: var(--border);
color: var(--text);
}
.btn-ghost:hover {
background: var(--surface);
text-decoration: none;
}
.hero-visual {
margin: 0;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), 0 0 80px var(--accent-soft);
}
.hero-visual img {
width: 100%;
height: 100%;
object-fit: cover;
aspect-ratio: 4 / 5;
}
.services,
.code-section,
.gallery-section,
.contact {
max-width: 1200px;
margin: 0 auto;
padding: 3rem clamp(1.25rem, 4vw, 3rem);
}
.services h2,
.code-section h2,
.gallery-section h2,
.contact h2 {
font-size: 1.75rem;
margin: 0 0 1.5rem;
}
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
}
.service-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
.service-card h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.service-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.95rem;
}
.section-head p {
color: var(--text-muted);
margin: -0.75rem 0 1.5rem;
max-width: 60ch;
}
.code-card {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
color: inherit;
text-decoration: none;
transition: border-color 0.2s ease, background 0.2s ease;
}
.code-card:hover {
text-decoration: none;
border-color: rgba(196, 123, 58, 0.45);
background: var(--surface);
}
.code-card h3 {
margin: 0 0 0.5rem;
font-family: var(--mono);
font-size: 1.1rem;
color: var(--accent);
}
.code-card p {
margin: 0;
color: var(--text-muted);
max-width: 50ch;
font-size: 0.95rem;
}
.gallery-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
align-items: center;
}
.gallery-controls-collections {
padding: 0.75rem 0 0;
margin-bottom: 1.25rem;
border-top: 1px solid var(--border);
}
.gallery-controls-label {
font-size: 0.8rem;
font-family: var(--mono);
color: var(--text-muted);
margin-right: 0.25rem;
width: 100%;
flex-basis: 100%;
}
@media (min-width: 600px) {
.gallery-controls-label {
width: auto;
flex-basis: auto;
}
}
.filter-btn {
font: inherit;
padding: 0.45rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.filter-btn:hover,
.filter-btn.active {
background: var(--accent-soft);
color: var(--text);
border-color: rgba(196, 123, 58, 0.45);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.gallery-item {
position: relative;
padding: 0;
border: none;
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
background: var(--surface);
aspect-ratio: 1;
}
.gallery-item img,
.gallery-placeholder {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.25s ease;
}
.gallery-placeholder {
display: block;
background: var(--surface);
}
.gallery-item:hover img {
transform: scale(1.04);
}
.gallery-album,
.gallery-date {
position: absolute;
left: 0.5rem;
font-size: 0.7rem;
font-family: var(--mono);
background: rgba(20, 16, 12, 0.8);
padding: 0.2rem 0.45rem;
border-radius: 4px;
max-width: calc(100% - 1rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gallery-album {
top: 0.5rem;
}
.gallery-date {
bottom: 0.5rem;
}
.gallery-loading,
.gallery-empty {
color: var(--text-muted);
grid-column: 1 / -1;
}
.contact-intro,
.contact-unavailable {
color: var(--text-muted);
max-width: 50ch;
}
.contact-form {
max-width: 32rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-top: 1.5rem;
}
.contact-form .form-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.4rem;
}
.contact-form .form-row label {
display: block;
font-size: 0.9rem;
font-weight: 500;
}
.contact-form .form-row input,
.contact-form .form-row textarea {
display: block;
width: 100%;
box-sizing: border-box;
font: inherit;
padding: 0.65rem 0.85rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
-webkit-appearance: none;
appearance: none;
}
.contact-form .form-row input:focus,
.contact-form .form-row textarea:focus {
outline: 2px solid var(--accent-soft);
border-color: rgba(196, 123, 58, 0.45);
}
.contact-form .form-row textarea {
resize: vertical;
min-height: 8rem;
}
.contact-form .btn {
align-self: flex-start;
}
.hp-field {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.contact-result {
margin-top: 1rem;
max-width: 32rem;
}
.alert {
padding: 1rem 1.15rem;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.alert p {
margin: 0;
}
.alert ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
.alert-success {
background: rgba(196, 123, 58, 0.12);
border-color: rgba(196, 123, 58, 0.35);
}
.alert-error {
background: rgba(120, 40, 40, 0.25);
border-color: rgba(180, 80, 80, 0.35);
}
.alert a {
color: var(--accent);
}
.site-footer {
border-top: 1px solid var(--border);
padding: 1.5rem clamp(1.25rem, 4vw, 3rem);
color: var(--text-muted);
font-size: 0.9rem;
}
.site-footer p {
margin: 0;
max-width: 1200px;
margin-inline: auto;
}
.site-footer a {
margin-left: 0.75rem;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.82);
display: grid;
place-items: center;
padding: 1.5rem;
z-index: 100;
}
.modal {
position: relative;
max-width: min(960px, 100%);
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
}
.modal-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 2.25rem;
height: 2.25rem;
border: none;
border-radius: 50%;
background: var(--surface);
color: var(--text);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
z-index: 2;
}
.modal-figure {
margin: 0;
}
.modal-figure img {
width: 100%;
max-height: 75vh;
object-fit: contain;
border-radius: 8px;
}
.modal-figure figcaption {
margin-top: 0.75rem;
color: var(--text-muted);
font-family: var(--mono);
font-size: 0.85rem;
}
.modal-nav {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
}
.modal-nav-btn {
font: inherit;
padding: 0.5rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
}
.modal-nav-btn:hover {
background: var(--accent-soft);
}
.htmx-request.gallery-grid {
opacity: 0.6;
}
/* --- Mobile --- */
@media (max-width: 768px) {
.site-header {
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.85rem 1rem;
}
.logo-text {
font-size: 0.95rem;
}
.site-nav {
width: 100%;
gap: 0.5rem 1rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 0.15rem;
}
.site-nav::-webkit-scrollbar {
display: none;
}
.site-nav a {
font-size: 0.9rem;
white-space: nowrap;
padding: 0.35rem 0;
}
.hero {
gap: 1.5rem;
padding: 1.75rem 1rem 2rem;
}
.hero h1 {
font-size: clamp(1.65rem, 8vw, 2.25rem);
}
.lead {
font-size: 1rem;
max-width: none;
}
.hero-actions {
flex-direction: column;
align-items: stretch;
}
.hero-actions .btn {
justify-content: center;
min-height: 2.75rem;
}
.services,
.code-section,
.gallery-section,
.contact {
padding: 2rem 1rem;
}
.service-grid {
grid-template-columns: 1fr;
}
.code-card {
flex-direction: column;
align-items: stretch;
padding: 1.25rem;
}
.code-card .btn {
justify-content: center;
width: 100%;
min-height: 2.75rem;
pointer-events: none;
}
.gallery-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.filter-btn {
min-height: 2.5rem;
padding: 0.5rem 1rem;
}
.contact-form {
max-width: none;
}
.contact-form .btn {
width: 100%;
justify-content: center;
min-height: 2.75rem;
}
.site-footer {
padding: 1.25rem 1rem;
font-size: 0.85rem;
}
.site-footer p {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
}
.site-footer a {
margin-left: 0;
}
.modal-backdrop {
padding: 0;
align-items: stretch;
}
.modal {
display: flex;
flex-direction: column;
max-width: none;
width: 100%;
min-height: 100dvh;
max-height: 100dvh;
border-radius: 0;
border: none;
padding: max(0.75rem, env(safe-area-inset-top))
max(0.75rem, env(safe-area-inset-right))
max(0.75rem, env(safe-area-inset-bottom))
max(0.75rem, env(safe-area-inset-left));
}
.modal-close {
top: max(0.5rem, env(safe-area-inset-top));
right: max(0.5rem, env(safe-area-inset-right));
width: 2.75rem;
height: 2.75rem;
}
.modal-figure {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.modal-figure img {
flex: 1;
min-height: 0;
max-height: none;
object-fit: contain;
}
.modal-nav {
flex-shrink: 0;
gap: 0.5rem;
}
.modal-nav-btn {
flex: 1;
min-height: 2.75rem;
text-align: center;
}
}
@media (max-width: 380px) {
.gallery-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (hover: hover) {
.gallery-item:hover img {
transform: scale(1.04);
}
}
@media (hover: none) {
.gallery-item:active img {
transform: scale(1.02);
}
}

View File

@@ -0,0 +1,76 @@
package templates
templ ContactForm(enabled bool) {
<section id="contact" class="contact">
<h2>Contact</h2>
<p class="contact-intro">
Interested in a project or collaboration? Send a message below.
</p>
if !enabled {
<p class="contact-unavailable">
The contact form is not configured yet. Email
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>
directly.
</p>
} else {
<form
class="contact-form"
method="post"
action="/contact"
hx-post="/contact"
hx-target="#contact-result"
hx-swap="innerHTML"
hx-boost="false"
hx-disabled-elt="find button[type=submit]"
>
<div class="form-row">
<label for="name">Name</label>
<input type="text" id="name" name="name" required autocomplete="name" maxlength="120"/>
</div>
<div class="form-row">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" maxlength="254"/>
</div>
<div class="form-row">
<label for="message">Message</label>
<textarea id="message" name="message" required rows="6" maxlength="8000"></textarea>
</div>
<input
class="hp-field"
type="text"
name="website"
id="website"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
<button type="submit" class="btn btn-primary">Send message</button>
</form>
}
<div id="contact-result" class="contact-result"></div>
</section>
}
templ ContactSuccess() {
<div class="alert alert-success" role="status">
<p>Thanks — your message has been sent. We will get back to you soon.</p>
</div>
}
templ ContactValidationAlert(errs map[string]string) {
<div class="alert alert-error" role="alert">
<p>Please fix the following:</p>
<ul>
for _, msg := range errs {
<li>{ msg }</li>
}
</ul>
</div>
}
templ ContactSendError() {
<div class="alert alert-error" role="alert">
<p>Something went wrong sending your message. Please try again later or email
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>.</p>
</div>
}

112
app/templates/gallery.templ Normal file
View File

@@ -0,0 +1,112 @@
package templates
import (
"fmt"
"time"
"technical.kiwi/website/internal/gallery"
)
templ GalleryGrid(images []gallery.Image) {
if len(images) == 0 {
<p class="gallery-empty">No images for this filter.</p>
} else {
for _, img := range images {
<button
type="button"
class="gallery-item"
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(img.RelPath)) }
hx-target="#modal-root"
hx-swap="innerHTML"
aria-label={ fmt.Sprintf("Open %s", gallery.AlbumLabel(img.Album)) }
>
if img.ThumbURL != "" {
<img
src={ img.ThumbURL }
alt={ fmt.Sprintf("%s — %s", gallery.AlbumLabel(img.Album), formatDate(img.Date)) }
loading="lazy"
decoding="async"
sizes="(max-width: 480px) 45vw, 160px"
/>
} else {
<span class="gallery-placeholder" aria-hidden="true"></span>
}
if img.Collection != "" {
<span class="gallery-album">{ img.Collection }</span>
} else if img.Album != "" {
<span class="gallery-album">{ gallery.AlbumLabel(img.Album) }</span>
}
if !img.Date.IsZero() {
<span class="gallery-date">{ formatDate(img.Date) }</span>
}
</button>
}
}
}
templ ImageModal(img gallery.Image, prevPath, nextPath string) {
<div
class="modal-backdrop"
hx-on:click="if (event.target === this) this.remove()"
>
<div class="modal" role="dialog" aria-modal="true" aria-label="Image viewer">
<button
type="button"
class="modal-close"
onclick="document.getElementById('modal-root').innerHTML = ''"
aria-label="Close"
>
&times;
</button>
<figure class="modal-figure">
<img src={ img.URL } alt={ img.Filename }/>
if !img.Date.IsZero() {
<figcaption>{ modalCaption(img) }</figcaption>
}
</figure>
<nav class="modal-nav" aria-label="Gallery navigation">
if prevPath != "" {
<button
type="button"
class="modal-nav-btn"
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(prevPath)) }
hx-target="#modal-root"
hx-swap="innerHTML"
>
&larr; Previous
</button>
} else {
<span></span>
}
if nextPath != "" {
<button
type="button"
class="modal-nav-btn"
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(nextPath)) }
hx-target="#modal-root"
hx-swap="innerHTML"
>
Next &rarr;
</button>
}
</nav>
</div>
</div>
}
func formatDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2 Jan 2006")
}
func modalCaption(img gallery.Image) string {
if img.Collection != "" {
return img.Collection + " — " + formatDate(img.Date)
}
if img.Album != "" {
return gallery.AlbumLabel(img.Album) + " — " + formatDate(img.Date)
}
return formatDate(img.Date)
}

7
app/templates/helpers.go Normal file
View File

@@ -0,0 +1,7 @@
package templates
import "time"
func currentYear() string {
return time.Now().Format("2006")
}

153
app/templates/home.templ Normal file
View File

@@ -0,0 +1,153 @@
package templates
import (
"fmt"
"technical.kiwi/website/internal/gallery"
)
templ Home(images []gallery.Image, hero gallery.Image, hasHero bool, contactEnabled bool) {
@Layout("Technical Kiwi Limited", heroPreload(hero, hasHero), homeContent(images, hero, hasHero, contactEnabled))
}
func heroPreload(hero gallery.Image, hasHero bool) string {
if !hasHero {
return ""
}
return hero.HeroURL
}
templ homeContent(images []gallery.Image, hero gallery.Image, hasHero bool, contactEnabled bool) {
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Technical Kiwi Limited</p>
<h1>Electronic engineering, DevOps, and interactive art</h1>
<p class="lead">
We design and build lighting installations, embedded systems, and the infrastructure
that keeps creative technology running — from workshop bench to stage.
</p>
<div class="hero-actions">
<a href="#gallery" class="btn btn-primary">View our work</a>
<a href="#contact" class="btn btn-ghost">Get in touch</a>
</div>
</div>
if hasHero && hero.HeroURL != "" {
<figure class="hero-visual">
<img
src={ hero.HeroURL }
alt="Technical Kiwi — Connection machine"
loading="eager"
decoding="sync"
fetchpriority="high"
/>
</figure>
}
</section>
<section id="services" class="services">
<h2>What we do</h2>
<div class="service-grid">
<article class="service-card">
<h3>Electronic engineering</h3>
<p>Custom LED systems, control electronics, PCB design, and fabrication for installations and products.</p>
</article>
<article class="service-card">
<h3>DevOps &amp; infrastructure</h3>
<p>Servers, CI/CD, monitoring, and reliable deployments — so your systems stay up when the show goes live.</p>
</article>
<article class="service-card">
<h3>Interactive art</h3>
<p>Lighting, sound-reactive visuals, and experiential tech for events, venues, and public spaces.</p>
</article>
</div>
</section>
<section id="code" class="code-section">
<div class="section-head">
<h2>Code</h2>
<p>
All repositories on
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
are written and maintained by Technical Kiwi — firmware, lighting control, web services,
and the infrastructure behind them.
</p>
</div>
<a
href="https://git.technical.kiwi/"
class="code-card"
rel="noopener noreferrer"
target="_blank"
>
<div class="code-card-copy">
<h3>git.technical.kiwi</h3>
<p>
Source for installations, internal tools, and open components we ship or deploy
{ "for" } clients. Browse projects, history, and releases in one place.
</p>
</div>
<span class="btn btn-primary">Browse repositories</span>
</a>
</section>
<section id="gallery" class="gallery-section">
<div class="section-head">
<h2>Gallery</h2>
<p>Installations, prototypes, and behind-the-scenes work.</p>
</div>
@GalleryControls(images)
@GalleryCollectionControls(images)
<div id="gallery-grid" class="gallery-grid">
@GalleryGrid(images)
</div>
</section>
@ContactForm(contactEnabled)
}
templ GalleryControls(images []gallery.Image) {
<div class="gallery-controls">
<button
type="button"
class="filter-btn active"
hx-get="/gallery"
hx-target="#gallery-grid"
hx-swap="innerHTML"
>
All
</button>
for _, album := range gallery.Albums(images) {
<button
type="button"
class="filter-btn"
hx-get={ fmt.Sprintf("/gallery?album=%s", album) }
hx-target="#gallery-grid"
hx-swap="innerHTML"
>
{ gallery.AlbumLabel(album) }
</button>
}
</div>
}
templ GalleryCollectionControls(images []gallery.Image) {
if len(gallery.Collections(images, "templeoftechno")) > 0 {
<div class="gallery-controls gallery-controls-collections">
<span class="gallery-controls-label">Temple of techno</span>
<button
type="button"
class="filter-btn"
hx-get="/gallery?album=templeoftechno"
hx-target="#gallery-grid"
hx-swap="innerHTML"
>
All events
</button>
for _, key := range gallery.Collections(images, "templeoftechno") {
<button
type="button"
class="filter-btn"
hx-get={ fmt.Sprintf("/gallery?album=templeoftechno&collection=%s", key) }
hx-target="#gallery-grid"
hx-swap="innerHTML"
>
{ gallery.CollectionLabel(key, images) }
</button>
}
</div>
}
}

View File

@@ -0,0 +1,43 @@
package templates
templ Layout(title string, preloadImage string, content templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="theme-color" content="#14100c"/>
<title>{ title }</title>
<meta name="description" content="Technical Kiwi Limited — electronic engineering, DevOps, and interactive art from New Zealand."/>
<link rel="stylesheet" href="/static/style.css"/>
if preloadImage != "" {
<link rel="preload" as="image" href={ preloadImage } fetchpriority="high"/>
}
</head>
<body hx-boost="true">
<header class="site-header">
<a href="/" class="logo">
<span class="logo-mark" aria-hidden="true">T</span>
<span class="logo-text">Technical Kiwi</span>
</a>
<nav class="site-nav">
<a href="#services">Services</a>
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">Code</a>
<a href="#gallery">Gallery</a>
<a href="#contact">Contact</a>
</nav>
</header>
<main>
@content
</main>
<footer class="site-footer">
<p>
&copy; { currentYear() } Technical Kiwi Limited. New Zealand.
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
</p>
</footer>
<div id="modal-root"></div>
<script src="/static/htmx.min.js" defer></script>
</body>
</html>
}

39
docker-compose.yaml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.9'
services:
website:
image: technical.kiwi/website
container_name: technicalkiwi
build: ./
env_file:
- .env
#networks:
# - caddy
labels:
caddy: technicalkiwi
caddy.reverse_proxy: "{{upstreams 8080}}"
dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: technicalkiwi-dev
ports:
- "7331:7331" # templ live-reload proxy — open this in the browser
- "8080:8080" # app direct (no auto-reload)
volumes:
- ./app:/app
- ${GOMODCACHE}:/go/pkg/mod
- ${GOCACHE}:/root/.cache/go-build
env_file:
- .env
environment:
ADDR: ":8080"
GOMODCACHE: /go/pkg/mod
GOCACHE: /root/.cache/go-build
working_dir: /app
#networks:
# caddy:
# external: true
# name: caddy