Refresh gallery metadata after thumbnail generation so new thumb URLs are available immediately, and lazy-load gallery thumbnails with IntersectionObserver to avoid fetching all images on initial page load. Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
5.2 KiB
Go
196 lines
5.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"technical.kiwi/website/internal/auth"
|
|
"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
|
|
Auth auth.Config
|
|
Sessions *auth.Sessions
|
|
galleryMu sync.RWMutex
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
authCfg := auth.Load()
|
|
srv := &Server{
|
|
ImagesDir: imagesDir,
|
|
StaticDir: staticDir,
|
|
Images: images,
|
|
Hero: hero,
|
|
HasHero: hasHero,
|
|
Mail: mailCfg,
|
|
ContactEnabled: mailCfg != nil,
|
|
Auth: authCfg,
|
|
Sessions: auth.NewSessions(),
|
|
}
|
|
if authCfg.Enabled {
|
|
log.Print("admin: enabled at /admin/")
|
|
} else {
|
|
log.Print("admin: disabled (set ADMIN_USER and ADMIN_PASSWORD)")
|
|
}
|
|
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
|
|
log.Printf("gallery thumbnails: %v", err)
|
|
} else {
|
|
log.Print("gallery thumbnails: ready")
|
|
}
|
|
// Thumbnail URLs are resolved during gallery.List. Re-list after derivative
|
|
// generation so newly-created thumbs appear immediately on first load.
|
|
if refreshed, err := gallery.List(imagesDir); err != nil {
|
|
log.Printf("gallery refresh after thumbnails: %v", err)
|
|
} else {
|
|
srv.Images = refreshed
|
|
if hero, hasHero := gallery.SelectHero(refreshed); hasHero {
|
|
srv.Hero = hero
|
|
srv.HasHero = true
|
|
}
|
|
}
|
|
return srv, nil
|
|
}
|
|
|
|
func (s *Server) snapshot() ([]gallery.Image, gallery.Image, bool) {
|
|
s.galleryMu.RLock()
|
|
defer s.galleryMu.RUnlock()
|
|
return s.Images, s.Hero, s.HasHero
|
|
}
|
|
|
|
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
images, hero, hasHero := s.snapshot()
|
|
templates.Home(images, hero, 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) imageModal(w http.ResponseWriter, r *http.Request) {
|
|
rel := r.PathValue("path")
|
|
if !gallery.SafeRelPath(rel) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
images, _, _ := s.snapshot()
|
|
img, ok := findImage(images, rel)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
idx := indexOf(images, rel)
|
|
prev, next := "", ""
|
|
if idx > 0 {
|
|
prev = images[idx-1].RelPath
|
|
}
|
|
if idx >= 0 && idx < len(images)-1 {
|
|
next = images[idx+1].RelPath
|
|
}
|
|
templates.ImageModal(img, prev, next).Render(r.Context(), w)
|
|
}
|
|
|
|
func findImage(images []gallery.Image, rel string) (gallery.Image, bool) {
|
|
for _, img := range images {
|
|
if img.RelPath == rel {
|
|
return img, true
|
|
}
|
|
}
|
|
return gallery.Image{}, false
|
|
}
|
|
|
|
func indexOf(images []gallery.Image, rel string) int {
|
|
for i, img := range 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/{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)))
|
|
|
|
mux.HandleFunc("GET /admin/login", s.adminLogin)
|
|
mux.HandleFunc("POST /admin/login", s.adminLoginPost)
|
|
mux.HandleFunc("POST /admin/logout", s.adminLogout)
|
|
mux.HandleFunc("GET /admin/", s.requireAdmin(s.adminIndex))
|
|
mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload))
|
|
mux.HandleFunc("POST /admin/delete", s.requireAdmin(s.adminDelete))
|
|
}
|