Compare commits

..
5 Commits
Author SHA1 Message Date
schreifuchs 297efaa4a6 fix(translations): stay on page
release.yaml / publish (push) Successful in 4m0s
2026-09-28 09:52:22 +02:00
schreifuchs c08dd355c0 fix(gallery): faster image loading
release.yaml / publish (push) Successful in 3m56s
2026-09-26 23:35:15 +02:00
schreifuchs 9ec2585030 feat(gallery): image detail view
release.yaml / publish (push) Successful in 4m0s
2026-09-26 19:28:00 +02:00
schreifuchs 72bc974aa6 feat(gallery): preload images
release.yaml / publish (push) Successful in 3m4s
2026-09-10 14:02:05 +02:00
schreifuchs c678c7aa45 fix(gallery): use correct aspect ratios
/ publish (push) Successful in 3m48s
2026-06-08 11:18:44 +02:00
17 changed files with 391 additions and 52 deletions

No files matched your search

-1
View File
@@ -91,4 +91,3 @@ To optimize images before uploading to Nextcloud:
```bash ```bash
convert input.png -resize 3000 -quality 80 output.webp convert input.png -resize 3000 -quality 80 output.webp
``` ```
+56 -17
View File
@@ -2,6 +2,7 @@ package gallery
import ( import (
"context" "context"
"log/slog"
"math" "math"
"github.com/InfinityTools/go-binpack2d" "github.com/InfinityTools/go-binpack2d"
@@ -19,16 +20,16 @@ type Set struct {
} }
func (s *Service) GetGallerySet(ctx context.Context) (gallerySet Set, err error) { func (s *Service) GetGallerySet(ctx context.Context) (gallerySet Set, err error) {
gallerySet.SM, err = s.GetGallery(ctx, 50) gallerySet.SM, err = s.getGallery(ctx, 50)
if err != nil { if err != nil {
return return
} }
gallerySet.LG, err = s.GetGallery(ctx, 80) gallerySet.LG, err = s.getGallery(ctx, 80)
if err != nil { if err != nil {
return return
} }
gallerySet.XLG, err = s.GetGallery(ctx, 120) gallerySet.XLG, err = s.getGallery(ctx, 120)
if err != nil { if err != nil {
return return
} }
@@ -36,18 +37,23 @@ func (s *Service) GetGallerySet(ctx context.Context) (gallerySet Set, err error)
return return
} }
func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, err error) { func (s *Service) getGallery(ctx context.Context, width int) (gallery Gallery, err error) {
imgs, err := s.GetImages(ctx) imgs, err := s.GetImages(ctx)
if err != nil { if err != nil {
return return
} }
packer := binpack2d.Create(width, 999999) packer := binpack2d.Create(width, math.MaxInt)
for len(imgs) > 0 { for len(imgs) > 0 {
unused := make([]Image, 0, len(imgs)) unused := make([]Image, 0, len(imgs))
for _, img := range imgs { for _, img := range imgs {
adjustImageSize(&img, 200) adjustImageSizeStrictRatio(&img, 200)
if img.Height == 0 || img.Width == 0 {
slog.Warn("image has invalid size", "img", img)
continue
}
rect, ok := packer.Insert(img.Width, img.Height, binpack2d.RULE_BEST_SHORT_SIDE_FIT) rect, ok := packer.Insert(img.Width, img.Height, binpack2d.RULE_BEST_SHORT_SIDE_FIT)
if !ok { if !ok {
unused = append(unused, img) unused = append(unused, img)
@@ -70,17 +76,50 @@ func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, e
return return
} }
// adjustImageSize adjusts the image size so that the aspect ratio stays the same // getCleanRatio returns the simplified aspect ratio as (numerator, denominator).
// but the image area is the given parameter. // It searches for the best fit within a denominator limit to account for rounding errors.
func adjustImageSize(img *Image, area int) { func getCleanRatio(width, height int) (int, int) {
// Calculate the original aspect ratio (W / H) targetRatio := float64(width) / float64(height)
aspectRatio := float64(img.Width) / float64(img.Height) const maxDenominator = 100
// Calculate new height and width using the derived formulas bestN, bestD := 0, 1
newHeight := math.Sqrt(float64(area) / aspectRatio) minDiff := math.MaxFloat64
newWidth := newHeight * aspectRatio
// Round to the nearest integer to minimize precision loss for d := 1; d <= maxDenominator; d++ {
img.Width = int(math.Round(newWidth)) // Calculate the closest numerator for this denominator
img.Height = int(math.Round(newHeight)) n := int(math.Round(float64(d) * targetRatio))
// Calculate the difference
currentRatio := float64(n) / float64(d)
diff := math.Abs(currentRatio - targetRatio)
// Keep track of the one with the smallest error
if diff < minDiff {
minDiff = diff
bestN, bestD = n, d
}
}
return bestN, bestD
}
func adjustImageSizeStrictRatio(img *Image, targetArea int) {
baseW, baseH := getCleanRatio(img.Width, img.Height)
baseArea := baseW * baseH
idealK := math.Sqrt(float64(targetArea) / float64(baseArea))
k := int(math.Floor(idealK))
if math.Abs(float64(k*k*baseArea-targetArea)) >
math.Abs(float64(int(math.Ceil(idealK))*int(math.Ceil(idealK))*baseArea-targetArea)) {
k = int(math.Ceil(idealK))
}
slog.Debug("adslkfj", "k", k, "idealK", idealK, "baseH", baseH, "baseW", baseW, "imgW", img.Width, "imgH", img.Height)
// 5. Apply the scaled dimensions (guarantees perfect aspect ratio)
img.Width = baseW * k
img.Height = baseH * k
} }
+46 -3
View File
@@ -11,6 +11,8 @@ import (
"strings" "strings"
"time" "time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
"github.com/valkey-io/valkey-go" "github.com/valkey-io/valkey-go"
) )
@@ -22,6 +24,10 @@ type Image struct {
Height int Height int
} }
func (i Image) URL() string {
return images.URLFromUID(i.UID)
}
const ( const (
imagesKey = "gallery:images:list" imagesKey = "gallery:images:list"
hashKey = "gallery:images:hash" hashKey = "gallery:images:hash"
@@ -45,7 +51,6 @@ func (s *Service) assembleImageList(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("could not read contents of %s: %w", s.cfg.Path, err) 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() savedHash, _ := s.cache.Do(ctx, s.cache.B().Get().Key(hashKey).Build()).AsBytes()
@@ -77,10 +82,19 @@ func (s *Service) assembleImageList(ctx context.Context) error {
continue continue
} }
w := img.Bounds().Dx()
h := img.Bounds().Dy()
// FIX 2: Defensive check to guard against unreadable/corrupted image boundaries
if w <= 0 || h <= 0 {
slog.Warn("skipping image with zero or negative dimensions", "path", imagePath, "w", w, "h", h)
continue
}
imgs = append(imgs, Image{ imgs = append(imgs, Image{
UID: uid, UID: uid,
Width: img.Bounds().Dx(), Width: w,
Height: img.Bounds().Dy(), Height: h,
}) })
} }
@@ -111,3 +125,32 @@ 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) err = s.cache.Do(ctx, s.cache.B().Get().Key(imagesKey).Build()).DecodeJSON(&images)
return return
} }
func (s *Service) GetPrevAndNext(ctx context.Context, uid string) (previous *Image, next *Image, err error) {
imgs, err := s.GetImages(ctx)
if err != nil {
return nil, nil, err
}
for i := range imgs {
if imgs[i].UID != uid {
continue
}
if i > 0 {
prevImg := imgs[i-1]
previous = &prevImg
}
if i+1 < len(imgs) {
nextImg := imgs[i+1]
next = &nextImg
}
return previous, next, nil
}
return nil, nil, &rest.Error{
Type: rest.ErrorTypeNotFound,
Message: "could not find image with uid",
}
}
+5
View File
@@ -33,6 +33,11 @@ func UIDFromPath(path string) (uid string, err error) {
func URLFromPath(path string) (url string, err error) { func URLFromPath(path string) (url string, err error) {
uid, err := UIDFromPath(path) uid, err := UIDFromPath(path)
url = URLFromUID(uid)
return
}
func URLFromUID(uid string) (url string) {
url = "/images/" + uid url = "/images/" + uid
return return
} }
+8
View File
@@ -53,13 +53,21 @@ func Path(ctx context.Context, pp ...string) string {
} }
func PathForLang(ctx context.Context, lang string, pp ...string) string { func PathForLang(ctx context.Context, lang string, pp ...string) string {
slog.Debug("computing path for lang", "pp", pp, "lang", lang)
s, ok := ctx.Value(serviceKey).(*Service) s, ok := ctx.Value(serviceKey).(*Service)
if !ok { if !ok {
slog.Error("could not extract translation service from context for translating path", "ctx", ctx, "path", pp) slog.Error("could not extract translation service from context for translating path", "ctx", ctx, "path", pp)
return path.Join(pp...) return path.Join(pp...)
} }
// remove leading ""
for len(pp) > 0 && pp[0] == "" {
pp = pp[1:]
}
if len(pp) > 0 { if len(pp) > 0 {
slog.Debug("computing path for lang", "pp", pp, "lang", lang, "pp[0]", pp[0], "translations", s.translations)
if _, ok := s.translations[pp[0]]; ok { if _, ok := s.translations[pp[0]]; ok {
pp = pp[1:] pp = pp[1:]
} }
+30 -3
View File
@@ -1,18 +1,45 @@
package restgallery package restgallery
import ( import (
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages"
) )
const preloadCount = 6
func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) { func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) {
imgs, err := h.srv.GetGallerySet(r.Context()) imgs, err := h.srv.GetGallerySet(r.Context())
if err != nil { if err != nil {
slog.Error("could not get images", "err", err) slog.Error("could not get gallery set", "err", err)
w.WriteHeader(http.StatusInternalServerError) rest.SendErr(w, err)
return return
} }
h.r.Render(r.Context(), w, r, pages.GallerySet(imgs)) ctx := r.Context()
for i, image := range imgs.LG.Images {
if i > preloadCount {
break
}
ctx = templer.AppendHeader(ctx, fmt.Sprintf(`<link rel="preload" href="%s?w=800" as="image" />`, image.URL()))
}
h.r.Render(ctx, w, r, pages.GallerySet(imgs))
}
func (h *Handler) image(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("image_uid")
slog.Debug("image from gallery", "uid", uid)
prev, next, err := h.srv.GetPrevAndNext(r.Context(), uid)
if err != nil {
slog.Error("could not get gallery", "err", err)
rest.SendErr(w, err)
return
}
h.r.Render(r.Context(), w, r, pages.GalleryImage(uid, next, prev))
} }
+5 -1
View File
@@ -28,12 +28,16 @@ func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
cfg: cfg, cfg: cfg,
} }
mux.HandleFunc(fmt.Sprintf("GET /%s", url.PathEscape(cfg.Path)), h.gallery) galleryPath := url.PathEscape(cfg.Path)
mux.HandleFunc(fmt.Sprintf("GET /%s/{image_uid}", galleryPath), h.image)
mux.HandleFunc(fmt.Sprintf("GET /%s", galleryPath), h.gallery)
return h return h
} }
type galleryService interface { type galleryService interface {
GetGallerySet(ctx context.Context) (gallerySet gallery.Set, err error) GetGallerySet(ctx context.Context) (gallerySet gallery.Set, err error)
GetPrevAndNext(ctx context.Context, uid string) (previous *gallery.Image, next *gallery.Image, err error)
} }
type renderer interface { type renderer interface {
Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
@@ -150,7 +150,7 @@ func TestCachedClient_Stat(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/test.txt", []byte("hello")) mockFS.AddFile("/test.txt", []byte("hello"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Cache miss // First call: Cache miss
info, err := c.Stat("/test.txt") info, err := c.Stat("/test.txt")
@@ -209,7 +209,7 @@ func TestCachedClient_Read(t *testing.T) {
content := []byte("hello world") content := []byte("hello world")
mockFS.AddFile("/read.txt", content) mockFS.AddFile("/read.txt", content)
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Miss // First call: Miss
data, err := c.Read("/read.txt") data, err := c.Read("/read.txt")
@@ -257,7 +257,7 @@ func TestCachedClient_ReadDir(t *testing.T) {
mockFS.AddFile("/dir/f1.txt", []byte("1")) mockFS.AddFile("/dir/f1.txt", []byte("1"))
mockFS.AddFile("/dir/f2.txt", []byte("2")) mockFS.AddFile("/dir/f2.txt", []byte("2"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Miss // First call: Miss
infos, err := c.ReadDir("/dir") infos, err := c.ReadDir("/dir")
@@ -303,7 +303,7 @@ func TestCachedClient_RevalidateStat(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/reval.txt", []byte("initial")) mockFS.AddFile("/reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, _ = c.Stat("/reval.txt") _, _ = c.Stat("/reval.txt")
@@ -381,7 +381,7 @@ func TestCachedClient_RevalidateRead(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/read_reval.txt", []byte("initial")) mockFS.AddFile("/read_reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, _ = c.Read("/read_reval.txt") _, _ = c.Read("/read_reval.txt")
@@ -431,7 +431,7 @@ func TestCachedClient_RevalidateReadDir(t *testing.T) {
mockFS.AddDir("/dir_reval") mockFS.AddDir("/dir_reval")
mockFS.AddFile("/dir_reval/f1.txt", []byte("1")) mockFS.AddFile("/dir_reval/f1.txt", []byte("1"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, err = c.ReadDir("/dir_reval") _, err = c.ReadDir("/dir_reval")
@@ -461,4 +461,3 @@ func TestCachedClient_RevalidateReadDir(t *testing.T) {
t.Errorf("expected 1 hit, got %d", c.hits.Load()) t.Errorf("expected 1 hit, got %d", c.hits.Load())
} }
} }
+30 -2
View File
@@ -2,8 +2,10 @@ package templer
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
) )
@@ -25,12 +27,38 @@ func Middleware(next http.Handler) http.HandlerFunc {
} }
} }
// Path returns the current path without a leading slash // Path returns the current path with a leading slash
func Path(ctx context.Context) string { func Path(ctx context.Context) string {
path, ok := ctx.Value(pathKey).(string) path, ok := ctx.Value(pathKey).(string)
if !ok { if !ok {
slog.Error("could not extract request path from context") slog.Error("could not extract request path from context")
return "" return ""
} }
return strings.TrimPrefix(path, "/") return path
}
func RelativePath(ctx context.Context, relPath string) (path string, err error) {
baseStr := Path(ctx)
if baseStr != "" && !strings.HasSuffix(baseStr, "/") {
baseStr += "/"
}
base, err := url.Parse(baseStr)
if err != nil {
err = fmt.Errorf("could not parse base path: %w", err)
return
}
rel, err := url.Parse(relPath)
if err != nil {
err = fmt.Errorf("could not parse relative path: %w", err)
return
}
// Resolve the relative path against the base
path = base.ResolveReference(rel).Path
if strings.HasSuffix(path, "/") {
path = path[:len(path)-1]
}
return
} }
+75
View File
@@ -0,0 +1,75 @@
package templer
import (
"context"
"testing"
)
func TestRelativePath(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
base string
relPath string
want string
wantErr bool
}{
// TODO: Add test cases.
{
name: "with no relative path stays the same",
base: "/foo/bar",
relPath: "",
want: "/foo/bar",
wantErr: false,
},
{
name: "with . relative path stays the same",
base: "/foo/bar",
relPath: ".",
want: "/foo/bar",
wantErr: false,
},
{
name: "with ./ relative path stays the same",
base: "/foo/bar",
relPath: "./",
want: "/foo/bar",
wantErr: false,
},
{
name: "with .. relative path goes one up",
base: "/foo/bar",
relPath: "..",
want: "/foo",
wantErr: false,
},
{
name: "with ../baz relative path replaces last element with baz",
base: "/foo/bar",
relPath: "../baz",
want: "/foo/baz",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.WithValue(t.Context(), pathKey, tt.base)
got, gotErr := RelativePath(ctx, tt.relPath)
if gotErr != nil {
if !tt.wantErr {
t.Errorf("RelativePath() failed: %v", gotErr)
}
return
}
if tt.wantErr {
t.Fatal("RelativePath() succeeded unexpectedly")
}
// TODO: update the condition below to compare got with tt.want.
if got != tt.want {
t.Errorf("RelativePath() = %v, want %v", got, tt.want)
}
})
}
}
+3 -2
View File
@@ -3,7 +3,6 @@ package server
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"path"
"time" "time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
@@ -78,7 +77,9 @@ func (s *Server) dynamicRoutes() (h http.Handler, err error) {
func() http.Handler { func() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle(path.Join("/"+s.cfg.Gallery.Path), restgallery.New(renderer, gallery.New(fs, s.cfg.Gallery, imageSrv, valkeyClient), s.cfg.Gallery)) galleryMux := restgallery.New(renderer, gallery.New(fs, s.cfg.Gallery, imageSrv, valkeyClient), s.cfg.Gallery)
mux.Handle("/"+s.cfg.Gallery.Path, galleryMux)
mux.Handle("/"+s.cfg.Gallery.Path+"/", galleryMux)
mux.Handle("/", resthome.New(renderer, page.New(fs))) mux.Handle("/", resthome.New(renderer, page.New(fs)))
return mux return mux
-5
View File
@@ -1,5 +0,0 @@
package components
templ Hello() {
<h1>Hello World</h1>
}
+27
View File
@@ -0,0 +1,27 @@
package components
// Modal renders a modal dialog.
//
// The <dialog> itself must never be an htmx swap target: swapping its
// innerHTML would replace the wrapper below (centering + close button) and
// break the layout. Content is swapped into the inner element with id
// "<id>-content" instead.
templ Modal(id string) {
<dialog
id={ id }
class="m-auto backdrop:bg-black/90 bg-transparent p-0 w-full h-full max-w-none max-h-none"
hx-on::after-swap="if (!this.open) this.showModal()"
>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<button
class="absolute right-5 top-5 z-10 p-2 text-white"
hx-on:click="this.closest('dialog').close()"
>
X
</button>
<div id={ id + "-content" } class="w-full h-full flex items-center justify-center overflow-hidden">
{ children... }
</div>
</div>
</dialog>
}
+18 -5
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
) )
templ Head(cfg config.Cfg, title string) { templ head(cfg config.Cfg, title string) {
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
@@ -29,8 +29,23 @@ templ Head(cfg config.Cfg, title string) {
templ Base(cfg config.Cfg, title string) { templ Base(cfg config.Cfg, title string) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang={ translate.Language(ctx) }> <html lang={ translate.Language(ctx) }>
@Head(cfg, title) @head(cfg, title)
<body class="bg-black dark" hx-boost="true" hx-target="main" hx-ext="head-support,preload,preload-links"> <body class="bg-black dark" hx-boost="true" hx-target="body" hx-ext="head-support,preload,preload-links">
@body() {
{ children... }
}
</body>
</html>
}
templ Content(cfg config.Cfg, title string) {
@head(cfg, title)
@body() {
{ children... }
}
}
templ body() {
<header <header
class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start" class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
> >
@@ -52,6 +67,4 @@ templ Base(cfg config.Cfg, title string) {
<main class="text-white"> <main class="text-white">
{ children... } { children... }
</main> </main>
</body>
</html>
} }
+1 -1
View File
@@ -36,7 +36,7 @@ func (l *Layouts) Render(ctx context.Context, w io.Writer, r *http.Request, comp
component = Base(l.cfg, title) component = Base(l.cfg, title)
} else { } else {
ctx = templ.WithChildren(ctx, component) ctx = templ.WithChildren(ctx, component)
component = Head(l.cfg, title) component = Content(l.cfg, title)
} }
if err := component.Render(ctx, w); err != nil { if err := component.Render(ctx, w); err != nil {
+80 -4
View File
@@ -3,30 +3,100 @@ package pages
import ( import (
"fmt" "fmt"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/components"
) )
const detailsID = "imgs-details"
// detailsContentID is the inner element of the modal that htmx swaps into.
// It is defined by components.Modal and must not be the dialog itself.
const detailsContentID = detailsID + "-content"
templ GallerySet(g gallery.Set) { templ GallerySet(g gallery.Set) {
<div class="p-5">
@components.Modal(detailsID) {
// Placeholder: only visible until the first image is swapped in.
// prev/next are nil because relative links would be resolved
// against the gallery index, not the image page.
if len(g.SM.Images) > 0 {
@GalleryImage(g.SM.Images[0].UID, nil, nil)
}
}
<div <div
class="grid gap-5 grid-cols-1 lg:hidden m-5" class="grid gap-5 grid-cols-1 lg:hidden"
> >
for _,img := range g.SM.Images { for _,img := range g.SM.Images {
<button
hx-get={ templer.RelativePath(ctx, "./"+img.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
class="block w-full cursor-pointer"
>
<img <img
src={ "/images/" + img.UID + "?w=600" } src={ "/images/" + img.UID + "?w=600" }
loading="lazy" loading="lazy"
class="w-full" class="w-full"
alt="Gallery item" alt="Gallery item"
/> />
</button>
} }
</div> </div>
<div class="hidden lg:block 3xl:hidden m-5"> <div class="hidden lg:block 3xl:hidden">
@Gallery(g.SM) @Gallery(g.SM)
</div> </div>
<div class="hidden 3xl:block 6xl:hidden m-5"> <div class="hidden 3xl:block 6xl:hidden">
@Gallery(g.LG) @Gallery(g.LG)
</div> </div>
<div class="hidden 6xl:block m-5"> <div class="hidden 6xl:block">
@Gallery(g.XLG) @Gallery(g.XLG)
</div> </div>
</div>
}
// Define a templ script to hold complex post-load behavior
templ GalleryImage(image string, next *gallery.Image, prev *gallery.Image) {
<link rel="preload" href={ "/images/" + image + "?w=800" } as="image"/>
<div class="relative w-10/12 h-10/12 flex items-center justify-center overflow-hidden ">
<!-- 1. Preloaded Base Image (w=800) -->
<img
src={ "/images/" + image + "?w=800" }
alt=""
class="absolute inset-0 w-full h-full object-contain"
/>
<!-- 2. Responsive Image Layer (Overlaid on top) -->
<img
src={ "/images/" + image + "?w=800" }
srcset={ "/images/" + image + "?w=400 400w, /images/" + image + "?w=800 800w, /images/" + image + "?w=1200 1200w, /images/" + image + "?w=1600 1600w" }
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="Image description"
onload="this.decode().then(() => {this.classList.remove('opacity-0')}).catch(() => {})"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-900 opacity-0"
/>
<!-- 3. Navigation Controls -->
if prev != nil {
<button
hx-get={ templer.RelativePath(ctx, "../"+prev.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
preload
class="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 text-white"
>
&lt;
</button>
}
if next != nil {
<button
hx-get={ templer.RelativePath(ctx, "../"+next.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
preload="mouseover"
class="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 text-white"
>
&gt;
</button>
}
</div>
} }
templ Gallery(g gallery.Gallery) { templ Gallery(g gallery.Gallery) {
@@ -38,6 +108,11 @@ templ Gallery(g gallery.Gallery) {
<div <div
style={ fmt.Sprintf("grid-column: %d / span %d; grid-row: %d / span %d;aspect-ratio: %d / %d;", img.X+1, img.Width, img.Y+1, img.Height, img.Width, img.Height) } style={ fmt.Sprintf("grid-column: %d / span %d; grid-row: %d / span %d;aspect-ratio: %d / %d;", img.X+1, img.Width, img.Y+1, img.Height, img.Width, img.Height) }
class="overflow-hidden shadow-sm" class="overflow-hidden shadow-sm"
>
<button
hx-get={ templer.RelativePath(ctx, "./"+img.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
> >
<img <img
src={ "/images/" + img.UID + "?w=800" } src={ "/images/" + img.UID + "?w=800" }
@@ -45,6 +120,7 @@ templ Gallery(g gallery.Gallery) {
class="w-full h-full object-cover" class="w-full h-full object-cover"
alt="Gallery item" alt="Gallery item"
/> />
</button>
</div> </div>
} }
</div> </div>
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{positLine truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pLine truncated