Compare commits

...
4 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
16 changed files with 370 additions and 77 deletions

No files matched your search

+4 -4
View File
@@ -20,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
} }
@@ -37,7 +37,7 @@ 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
+35
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"
@@ -119,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)
+10 -11
View File
@@ -98,7 +98,7 @@ func (m *MockFS) ReadDir(path string) ([]os.FileInfo, error) {
if len(prefix) > 0 && prefix[len(prefix)-1] != '/' { if len(prefix) > 0 && prefix[len(prefix)-1] != '/' {
prefix += "/" prefix += "/"
} }
if len(p) > len(prefix) && p[:len(prefix)] == prefix { if len(p) > len(prefix) && p[:len(prefix)] == prefix {
// Ensure direct child (no more slashes) // Ensure direct child (no more slashes)
rel := p[len(prefix):] rel := p[len(prefix):]
@@ -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")
@@ -342,7 +342,7 @@ func TestCachedClient_RevalidateStat(t *testing.T) {
// We can inspect redis directly or trust `Stat` returns cached value (which should be updated). // We can inspect redis directly or trust `Stat` returns cached value (which should be updated).
// Let's inspect redis to be sure. // Let's inspect redis to be sure.
// Or just call Stat again. It should be a cache HIT, but return NEW values. // Or just call Stat again. It should be a cache HIT, but return NEW values.
// Reset stats to check hit // Reset stats to check hit
c.hits.Store(0) c.hits.Store(0)
c.misses.Store(0) c.misses.Store(0)
@@ -351,11 +351,11 @@ func TestCachedClient_RevalidateStat(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Stat failed: %v", err) t.Fatalf("Stat failed: %v", err)
} }
if info.Size() != int64(len("updated content")) { if info.Size() != int64(len("updated content")) {
t.Errorf("expected size %d, got %d", len("updated content"), info.Size()) t.Errorf("expected size %d, got %d", len("updated content"), info.Size())
} }
// Should be a hit because revalidate updated the cache entry, so it exists and is valid. // Should be a hit because revalidate updated the cache entry, so it exists and is valid.
if c.hits.Load() != 1 { if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load()) t.Errorf("expected 1 hit, got %d", c.hits.Load())
@@ -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>
}
+36 -23
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,29 +29,42 @@ 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">
<header @body() {
class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
>
<a class="text-white text-2xl" href={ translate.Path(ctx, "") } preload="mouseover" preload-images="true">schreifuchs.ch</a>
<nav class="flex gap-2 p-2">
for _, lang := range translate.SupportedLanguages(ctx) {
<a
href={ translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...) }
hreflang={ lang }
data-no-translate
preload="mouseover"
class="text-white hover:underline uppercase"
>
{ lang }
</a>
}
</nav>
</header>
<main class="text-white">
{ children... } { children... }
</main> }
</body> </body>
</html> </html>
} }
templ Content(cfg config.Cfg, title string) {
@head(cfg, title)
@body() {
{ children... }
}
}
templ body() {
<header
class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
>
<a class="text-white text-2xl" href={ translate.Path(ctx, "") } preload="mouseover" preload-images="true">schreifuchs.ch</a>
<nav class="flex gap-2 p-2">
for _, lang := range translate.SupportedLanguages(ctx) {
<a
href={ translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...) }
hreflang={ lang }
data-no-translate
preload="mouseover"
class="text-white hover:underline uppercase"
>
{ lang }
</a>
}
</nav>
</header>
<main class="text-white">
{ children... }
</main>
}
+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 {
+100 -24
View File
@@ -3,29 +3,99 @@ 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 <div class="p-5">
class="grid gap-5 grid-cols-1 lg:hidden m-5" @components.Modal(detailsID) {
> // Placeholder: only visible until the first image is swapped in.
for _,img := range g.SM.Images { // prev/next are nil because relative links would be resolved
<img // against the gallery index, not the image page.
src={ "/images/" + img.UID + "?w=600" } if len(g.SM.Images) > 0 {
loading="lazy" @GalleryImage(g.SM.Images[0].UID, nil, nil)
class="w-full" }
alt="Gallery item"
/>
} }
<div
class="grid gap-5 grid-cols-1 lg:hidden"
>
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
src={ "/images/" + img.UID + "?w=600" }
loading="lazy"
class="w-full"
alt="Gallery item"
/>
</button>
}
</div>
<div class="hidden lg:block 3xl:hidden">
@Gallery(g.SM)
</div>
<div class="hidden 3xl:block 6xl:hidden">
@Gallery(g.LG)
</div>
<div class="hidden 6xl:block">
@Gallery(g.XLG)
</div>
</div> </div>
<div class="hidden lg:block 3xl:hidden m-5"> }
@Gallery(g.SM)
</div> // Define a templ script to hold complex post-load behavior
<div class="hidden 3xl:block 6xl:hidden m-5"> templ GalleryImage(image string, next *gallery.Image, prev *gallery.Image) {
@Gallery(g.LG) <link rel="preload" href={ "/images/" + image + "?w=800" } as="image"/>
</div> <div class="relative w-10/12 h-10/12 flex items-center justify-center overflow-hidden ">
<div class="hidden 6xl:block m-5"> <!-- 1. Preloaded Base Image (w=800) -->
@Gallery(g.XLG) <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> </div>
} }
@@ -39,12 +109,18 @@ templ Gallery(g gallery.Gallery) {
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"
> >
<img <button
src={ "/images/" + img.UID + "?w=800" } hx-get={ templer.RelativePath(ctx, "./"+img.UID) }
loading="lazy" hx-target={ "#" + detailsContentID }
class="w-full h-full object-cover" hx-swap="innerHTML"
alt="Gallery item" >
/> <img
src={ "/images/" + img.UID + "?w=800" }
loading="lazy"
class="w-full h-full object-cover"
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