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:
151
app/internal/gallery/thumbs.go
Normal file
151
app/internal/gallery/thumbs.go
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user