feat: image gallery (#2)
/ publish (push) Successful in 4m4s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-06-01 14:07:50 +02:00
parent ee3eba3cd9
commit 763163c24e
30 changed files with 513 additions and 44 deletions
+86
View File
@@ -0,0 +1,86 @@
package gallery
import (
"context"
"math"
"github.com/InfinityTools/go-binpack2d"
)
type Gallery struct {
GridWith int
GridHeight int
Images []Image
}
type Set struct {
SM Gallery
LG Gallery
XLG Gallery
}
func (s *Service) GetGallerySet(ctx context.Context) (gallerySet Set, err error) {
gallerySet.SM, err = s.GetGallery(ctx, 50)
if err != nil {
return
}
gallerySet.LG, err = s.GetGallery(ctx, 80)
if err != nil {
return
}
gallerySet.XLG, err = s.GetGallery(ctx, 120)
if err != nil {
return
}
return
}
func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, err error) {
imgs, err := s.GetImages(ctx)
if err != nil {
return
}
packer := binpack2d.Create(width, 999999)
for len(imgs) > 0 {
unused := make([]Image, 0, len(imgs))
for _, img := range imgs {
adjustImageSize(&img, 200)
rect, ok := packer.Insert(img.Width, img.Height, binpack2d.RULE_BEST_SHORT_SIDE_FIT)
if !ok {
unused = append(unused, img)
continue
}
img.X = rect.X
img.Y = rect.Y
gallery.Images = append(gallery.Images, img)
}
imgs = unused
}
packer.ShrinkBin(false)
gallery.GridHeight = packer.GetHeight()
gallery.GridWith = packer.GetWidth()
return
}
// adjustImageSize adjusts the image size so that the aspect ratio stays the same
// but the image area is the given parameter.
func adjustImageSize(img *Image, area int) {
// Calculate the original aspect ratio (W / H)
aspectRatio := float64(img.Width) / float64(img.Height)
// Calculate new height and width using the derived formulas
newHeight := math.Sqrt(float64(area) / aspectRatio)
newWidth := newHeight * aspectRatio
// Round to the nearest integer to minimize precision loss
img.Width = int(math.Round(newWidth))
img.Height = int(math.Round(newHeight))
}
+40
View File
@@ -0,0 +1,40 @@
package gallery
import (
"os"
"path"
)
type fileWithPath struct {
os.FileInfo
path string
}
func (f fileWithPath) Name() string {
return path.Join(f.path, f.FileInfo.Name())
}
func (s *Service) readDirRecursive(path string) (files []os.FileInfo, err error) {
return s.readDirRecursiveTo([]os.FileInfo{}, path)
}
func (s *Service) readDirRecursiveTo(files []os.FileInfo, p ...string) ([]os.FileInfo, error) {
localFiles, err := s.fs.ReadDir(path.Join(p...))
if err != nil {
return files, err
}
for _, file := range localFiles {
if file.IsDir() {
newP := append(p, file.Name())
files, err = s.readDirRecursiveTo(files, newP...)
if err != nil {
return files, err
}
} else {
files = append(files, fileWithPath{file, path.Join(p[1:]...)})
}
}
return files, nil
}
+113
View File
@@ -0,0 +1,113 @@
package gallery
import (
"bytes"
"context"
"fmt"
"hash/fnv"
"log/slog"
"os"
"path"
"strings"
"time"
"github.com/valkey-io/valkey-go"
)
type Image struct {
UID string
X int
Y int
Width int
Height int
}
const (
imagesKey = "gallery:images:list"
hashKey = "gallery:images:hash"
)
func (s *Service) autoRefresh() {
t := time.NewTicker(time.Minute)
for {
ctx, cancle := context.WithTimeout(context.Background(), time.Minute)
if err := s.assembleImageList(ctx); err != nil {
slog.Error("error while refreshing gallery", "err", err)
}
cancle()
<-t.C
}
}
func (s *Service) assembleImageList(ctx context.Context) error {
files, err := s.readDirRecursive(s.cfg.Path)
if err != nil {
return fmt.Errorf("could not read contents of %s: %w", s.cfg.Path, err)
}
slog.Debug("loaded files", "files", files)
savedHash, _ := s.cache.Do(ctx, s.cache.B().Get().Key(hashKey).Build()).AsBytes()
newHash := hashImageList(files)
if bytes.Equal(savedHash, newHash) {
slog.Debug("images up to date no refreshing of gallery", "savedHash", savedHash, "newHash", newHash)
return nil
}
slog.Info("starting gallery refresh")
defer slog.Info("gallery refresh completed")
imgs := make([]Image, 0, len(files))
for _, file := range files {
if strings.HasSuffix(file.Name(), ".md") {
continue
}
imagePath := path.Join(s.cfg.Path, file.Name())
img, err := s.img.GetImage(imagePath)
if err != nil || img.Image == nil {
slog.Warn("could not get image for gallery", "err", err, "img", img)
continue
}
uid, err := img.UID()
if err != nil {
continue
}
imgs = append(imgs, Image{
UID: uid,
Width: img.Bounds().Dx(),
Height: img.Bounds().Dy(),
})
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(hashKey).Value(valkey.BinaryString(newHash)).Build()).Error(); err != nil {
return err
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(imagesKey).Value(valkey.JSON(imgs)).Build()).Error(); err != nil {
return err
}
return nil
}
func hashImageList(files []os.FileInfo) []byte {
h := fnv.New128()
for _, file := range files {
fmt.Fprint(h, file.ModTime())
fmt.Fprint(h, file.Size())
fmt.Fprint(h, file.Name())
}
return h.Sum([]byte{})
}
func (s *Service) GetImages(ctx context.Context) (images []Image, err error) {
err = s.cache.Do(ctx, s.cache.B().Get().Key(imagesKey).Build()).DecodeJSON(&images)
return
}
+35
View File
@@ -0,0 +1,35 @@
package gallery
import (
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"github.com/valkey-io/valkey-go"
)
type getImager interface {
GetImage(path string) (img images.Image, err error)
}
type Service struct {
fs filesystem.FS
cfg *config.Gallery
img getImager
cache valkey.Client
refreshIntervall time.Duration
}
func New(fs filesystem.FS, cfg *config.Gallery, img getImager, cache valkey.Client) *Service {
s := &Service{
fs: fs,
cfg: cfg,
img: img,
cache: cache,
}
go s.autoRefresh()
return s
}