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