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:
183
app/internal/handlers/handlers.go
Normal file
183
app/internal/handlers/handlers.go
Normal 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user