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>
152 lines
3.5 KiB
Go
152 lines
3.5 KiB
Go
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"
|
|
}
|