Compare commits

..
Author SHA1 Message Date
schreifuchs 2db36e2ada fieat(gallery): recursive image search 2026-06-01 13:45:41 +02:00
schreifuchs 9ca81d7f13 fix(gallery): config 2026-06-01 13:23:04 +02:00
schreifuchs c55e6bc43f feat(gallery): reactivity 2026-06-01 13:07:32 +02:00
schreifuchs efcc32b522 feat(gallery): caching 2026-06-01 12:38:09 +02:00
schreifuchs 8a72b96e66 feat(gallery): packing 2026-05-31 15:48:18 +02:00
schreifuchs 9282f1f634 feat(gallery): routes 2026-05-31 10:46:07 +02:00
35 changed files with 198 additions and 570 deletions

No files matched your search

-8
View File
@@ -27,11 +27,3 @@ jobs:
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: "git.schreifuchs.ch/schreifuchs/schreifuchs.ch:${{ github.ref_name }},git.schreifuchs.ch/schreifuchs/schreifuchs.ch:latest" tags: "git.schreifuchs.ch/schreifuchs/schreifuchs.ch:${{ github.ref_name }},git.schreifuchs.ch/schreifuchs/schreifuchs.ch:latest"
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
state: "published"
title: "Release ${{ github.ref_name }}"
notes: "Automated release for tag ${{ github.ref_name }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -26,7 +26,7 @@ COPY . .
# Copy built frontend assets from the previous stage # Copy built frontend assets from the previous stage
COPY --from=frontend-builder /app/web/static/css/output.css ./web/static/css/ COPY --from=frontend-builder /app/web/static/css/output.css ./web/static/css/
COPY --from=frontend-builder /app/web/static/js/ ./web/static/js/ COPY --from=frontend-builder /app/web/static/js/htmx.min.js ./web/static/js/
# Generate templ files using the tool defined in go.mod # Generate templ files using the tool defined in go.mod
RUN go tool templ generate RUN go tool templ generate
+1
View File
@@ -91,3 +91,4 @@ 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
``` ```
+1 -1
View File
@@ -5,7 +5,6 @@ go 1.26
tool github.com/a-h/templ/cmd/templ tool github.com/a-h/templ/cmd/templ
require ( require (
github.com/InfinityTools/go-binpack2d v1.0.0
github.com/a-h/templ v0.3.977 github.com/a-h/templ v0.3.977
github.com/alicebob/miniredis/v2 v2.36.1 github.com/alicebob/miniredis/v2 v2.36.1
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
@@ -19,6 +18,7 @@ require (
) )
require ( require (
github.com/InfinityTools/go-binpack2d v1.0.0 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+17 -56
View File
@@ -2,7 +2,6 @@ package gallery
import ( import (
"context" "context"
"log/slog"
"math" "math"
"github.com/InfinityTools/go-binpack2d" "github.com/InfinityTools/go-binpack2d"
@@ -20,16 +19,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,23 +36,18 @@ 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, math.MaxInt) packer := binpack2d.Create(width, 999999)
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 {
adjustImageSizeStrictRatio(&img, 200) adjustImageSize(&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)
@@ -76,50 +70,17 @@ func (s *Service) getGallery(ctx context.Context, width int) (gallery Gallery, e
return return
} }
// getCleanRatio returns the simplified aspect ratio as (numerator, denominator). // adjustImageSize adjusts the image size so that the aspect ratio stays the same
// It searches for the best fit within a denominator limit to account for rounding errors. // but the image area is the given parameter.
func getCleanRatio(width, height int) (int, int) { func adjustImageSize(img *Image, area int) {
targetRatio := float64(width) / float64(height) // Calculate the original aspect ratio (W / H)
const maxDenominator = 100 aspectRatio := float64(img.Width) / float64(img.Height)
bestN, bestD := 0, 1 // Calculate new height and width using the derived formulas
minDiff := math.MaxFloat64 newHeight := math.Sqrt(float64(area) / aspectRatio)
newWidth := newHeight * aspectRatio
for d := 1; d <= maxDenominator; d++ { // Round to the nearest integer to minimize precision loss
// Calculate the closest numerator for this denominator img.Width = int(math.Round(newWidth))
n := int(math.Round(float64(d) * targetRatio)) img.Height = int(math.Round(newHeight))
// 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
} }
+3 -46
View File
@@ -11,8 +11,6 @@ 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"
) )
@@ -24,10 +22,6 @@ 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"
@@ -51,6 +45,7 @@ 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()
@@ -82,19 +77,10 @@ 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: w, Width: img.Bounds().Dx(),
Height: h, Height: img.Bounds().Dy(),
}) })
} }
@@ -125,32 +111,3 @@ 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,11 +33,6 @@ 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,21 +53,13 @@ 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:]
} }
+3 -30
View File
@@ -1,45 +1,18 @@
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 gallery set", "err", err) slog.Error("could not get images", "err", err)
rest.SendErr(w, err) w.WriteHeader(http.StatusInternalServerError)
return return
} }
ctx := r.Context() h.r.Render(r.Context(), w, r, pages.GallerySet(imgs))
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.RenderPage(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.RenderComponent(r.Context(), w, r, pages.GalleryImage(uid, next, prev))
} }
+2 -7
View File
@@ -28,18 +28,13 @@ func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
cfg: cfg, cfg: cfg,
} }
galleryPath := url.PathEscape(cfg.Path) mux.HandleFunc(fmt.Sprintf("GET /%s", url.PathEscape(cfg.Path)), h.gallery)
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 {
RenderPage(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)
RenderComponent(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
} }
+2 -2
View File
@@ -17,7 +17,7 @@ func (h *Handler) home(w http.ResponseWriter, r *http.Request) {
return return
} }
h.r.RenderPage(r.Context(), w, r, pages.Home(pageHeaders)) h.r.Render(r.Context(), w, r, pages.Home(pageHeaders))
} }
func (h *Handler) page(w http.ResponseWriter, r *http.Request) { func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
@@ -43,5 +43,5 @@ func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
ctx = templer.PageTitle(ctx, page.Title) ctx = templer.PageTitle(ctx, page.Title)
h.r.RenderPage(ctx, w, r, pages.ContentPage(page)) h.r.Render(ctx, w, r, pages.ContentPage(page))
} }
+1 -1
View File
@@ -33,5 +33,5 @@ type pageService interface {
GetPage(ctx context.Context, uid string) (page.Page, error) GetPage(ctx context.Context, uid string) (page.Page, error)
} }
type renderer interface { type renderer interface {
RenderPage(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)
} }
+2 -4
View File
@@ -10,10 +10,8 @@ func Default() Cfg {
CacheLifetime: time.Hour * 24 * 31, CacheLifetime: time.Hour * 24 * 31,
}, },
Tracking: &Umami{ Tracking: &Umami{
ScriptSrc: "https://umami.schreifuchs.ch/script.js", ScriptSrc: "https://umami.schreifuchs.ch/script.js",
RecorderSrc: "https://umami.schreifuchs.ch/recorder.js", WebsiteID: "54d8a379-77d5-4c20-b46d-5c9a6c9e39bd",
WebsiteID: "54d8a379-77d5-4c20-b46d-5c9a6c9e39bd",
PerformanceEnabled: true,
}, },
Gallery: &Gallery{ Gallery: &Gallery{
Path: "photos", Path: "photos",
+2 -4
View File
@@ -52,10 +52,8 @@ type Gallery struct {
} }
type Umami struct { type Umami struct {
ScriptSrc string `env:"SCRIPT_SRC"` ScriptSrc string `env:"SCRIPT_SRC"`
RecorderSrc string `env:"RECORDER_SRC"` WebsiteID string `env:"WEBSITE_ID"`
WebsiteID string `env:"WEBSITE_ID"`
PerformanceEnabled bool `env:"PERFORMANCE_ENABLED`
} }
func Read(ctx context.Context) (cfg Cfg, err error) { func Read(ctx context.Context) (cfg Cfg, err error) {
+11 -10
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, time.Hour*2) c := NewCachedClient(mockFS, client, time.Hour)
// 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, time.Hour*2) c := NewCachedClient(mockFS, client, time.Hour)
// 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, time.Hour*2) c := NewCachedClient(mockFS, client, time.Hour)
// 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, time.Hour*2) c := NewCachedClient(mockFS, client, time.Hour)
// 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, time.Hour*2) c := NewCachedClient(mockFS, client, time.Hour)
// 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, time.Hour*2) c := NewCachedClient(mockFS, client, time.Hour)
// Populate cache // Populate cache
_, err = c.ReadDir("/dir_reval") _, err = c.ReadDir("/dir_reval")
@@ -461,3 +461,4 @@ 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())
} }
} }
+2 -30
View File
@@ -2,10 +2,8 @@ package templer
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
) )
@@ -27,38 +25,12 @@ func Middleware(next http.Handler) http.HandlerFunc {
} }
} }
// Path returns the current path with a leading slash // Path returns the current path without 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 path return strings.TrimPrefix(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
@@ -1,75 +0,0 @@
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)
}
})
}
}
+2 -3
View File
@@ -3,6 +3,7 @@ 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"
@@ -77,9 +78,7 @@ func (s *Server) dynamicRoutes() (h http.Handler, err error) {
func() http.Handler { func() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
galleryMux := restgallery.New(renderer, gallery.New(fs, s.cfg.Gallery, imageSrv, valkeyClient), s.cfg.Gallery) mux.Handle(path.Join("/"+s.cfg.Gallery.Path), 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
+3 -1
View File
@@ -21,6 +21,8 @@
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"dependencies": { "dependencies": {
"htmx.org": "^4.0.0" "htmx-ext-head-support": "^2.0.5",
"htmx-ext-preload": "^2.1.2",
"htmx.org": "^2.0.8"
} }
} }
+25 -6
View File
@@ -8,9 +8,15 @@ importers:
.: .:
dependencies: dependencies:
htmx-ext-head-support:
specifier: ^2.0.5
version: 2.0.5
htmx-ext-preload:
specifier: ^2.1.2
version: 2.1.2
htmx.org: htmx.org:
specifier: ^4.0.0 specifier: ^2.0.8
version: 4.0.0 version: 2.0.8
devDependencies: devDependencies:
'@tailwindcss/cli': '@tailwindcss/cli':
specifier: ^4.1.18 specifier: ^4.1.18
@@ -441,9 +447,14 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
htmx.org@4.0.0: htmx-ext-head-support@2.0.5:
resolution: {integrity: sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==} resolution: {integrity: sha512-fTzqnCw7OnMijiS48CCBFBHTuOMvycbX0nEFRL0lkRxyu5DFWX90IV1SwXpQ92fJf02IzkXcRNyjXn3j0QtYxA==}
hasBin: true
htmx-ext-preload@2.1.2:
resolution: {integrity: sha512-foD06XAITUUQApCUQnPexyJ6B7+4vFGMQ+t42unTVTGRu/CYHdAAt6oHg7eekPHT7NRnbM5hjnGVKtClryPabg==}
htmx.org@2.0.8:
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==}
is-extglob@2.1.1: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
@@ -889,7 +900,15 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
htmx.org@4.0.0: {} htmx-ext-head-support@2.0.5:
dependencies:
htmx.org: 2.0.8
htmx-ext-preload@2.1.2:
dependencies:
htmx.org: 2.0.8
htmx.org@2.0.8: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
+2 -2
View File
@@ -4,8 +4,8 @@ import path from "node:path";
const filesToCopy = [ const filesToCopy = [
"node_modules/htmx.org/dist/htmx.min.js", "node_modules/htmx.org/dist/htmx.min.js",
"node_modules/htmx.org/dist/ext/hx-preload.min.js", "node_modules/htmx-ext-preload/dist/preload.min.js",
"node_modules/htmx.org/dist/ext/hx-head.min.js", "node_modules/htmx-ext-head-support/dist/head-support.min.js",
]; ];
const filesToMinifyRoot = "web/src/js"; const filesToMinifyRoot = "web/src/js";
+5
View File
@@ -0,0 +1,5 @@
package components
templ Hello() {
<h1>Hello World</h1>
}
-34
View File
@@ -1,34 +0,0 @@
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.
//
// In htmx 4 "htmx:after:swap" fires on the requesting element, which lives
// outside this dialog (e.g. gallery grid buttons). The listener is therefore
// attached document-wide via the bare hx-on form with "from:body" (the
// hx-on:<event> form cannot carry trigger specs: attribute names may not
// contain spaces) and guarded by ctx.target so only swaps into this dialog's
// content open it.
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="htmx:after:swap from:body -> if (!this.open && this.contains(ctx.target)) 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>
}
+27 -56
View File
@@ -7,80 +7,51 @@ 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"/>
<meta name="htmx-config" content={ `{"noSwap": [204, 304, "4xx", "5xx"], "preload": {"boostEvent": "mouseover"}}` }/>
<title>{ title }</title> <title>{ title }</title>
<link rel="icon" type="image/png" href="/favicon.png"/> <link rel="icon" type="image/png" href="/favicon.png"/>
<link fetchpriority="high" href="/css/output.css" rel="stylesheet"/> <link fetchpriority="high" href="/css/output.css" rel="stylesheet"/>
<script src="/js/htmx.min.js"></script> <script src="/js/htmx.min.js"></script>
<script src="/js/hx-head.min.js"></script> <script src="/js/head-support.min.js"></script>
<script src="/js/hx-preload.min.js"></script> <script src="/js/preload.min.js"></script>
<script src="/js/preload-links.min.js"></script> <script src="/js/preload-links.min.js"></script>
<script <script src={ cfg.Tracking.ScriptSrc } data-website-id={ cfg.Tracking.WebsiteID }></script>
src={ cfg.Tracking.ScriptSrc }
data-website-id={ cfg.Tracking.WebsiteID }
data-performance={cfg.Tracking.PerformanceEnabled }
></script>
if cfg.Tracking.RecorderSrc != "" {
<script src={ cfg.Tracking.RecorderSrc } data-website-id={ cfg.Tracking.WebsiteID }></script>
}
for _, h := range templer.GetHeader(ctx) { for _, h := range templer.GetHeader(ctx) {
@templ.Raw(h) @templ.Raw(h)
} }
</head> </head>
{ children... }
} }
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:inherited="true" hx-target:inherited="body"> <body class="bg-black dark" hx-boost="true" hx-target="main" hx-ext="head-support,preload,preload-links">
@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... } { children... }
} </main>
</body> </body>
</html> </html>
} }
templ Page(cfg config.Cfg, title string) {
@head(cfg, title)
@body() {
{ children... }
}
}
templ Component() {
for _, h := range templer.GetHeader(ctx) {
@templ.Raw(h)
}
{ 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, "") } hx-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
hx-preload="mouseover"
class="text-white hover:underline uppercase"
>
{ lang }
</a>
}
</nav>
</header>
<main class="text-white">
{ children... }
</main>
}
+7 -13
View File
@@ -2,6 +2,7 @@ package layouts
import ( import (
"context" "context"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -21,9 +22,12 @@ func New(cfg config.Cfg) *Layouts {
} }
} }
// RenderPage renders a page. If the query paramenter HX-Request is set // Render renders a page. If the query paramenter "c" is set
// the component gets rendered without the layout // the component gets rendered without the layout
func (l *Layouts) RenderPage(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) { func (l *Layouts) Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) {
withLayout := !r.URL.Query().Has("c")
fmt.Println("layout: ", withLayout)
title := templer.GetTitle(ctx, "Schreifuchs") title := templer.GetTitle(ctx, "Schreifuchs")
slog.Debug("HX-Request", "value", r.Header.Get("HX-Request")) slog.Debug("HX-Request", "value", r.Header.Get("HX-Request"))
@@ -32,20 +36,10 @@ func (l *Layouts) RenderPage(ctx context.Context, w io.Writer, r *http.Request,
component = Base(l.cfg, title) component = Base(l.cfg, title)
} else { } else {
ctx = templ.WithChildren(ctx, component) ctx = templ.WithChildren(ctx, component)
component = Page(l.cfg, title) component = Head(l.cfg, title)
} }
if err := component.Render(ctx, w); err != nil { if err := component.Render(ctx, w); err != nil {
slog.Error("error while rendering component", "err", err, "ctx", ctx) slog.Error("error while rendering component", "err", err, "ctx", ctx)
} }
} }
// RenderComponent renders a component alone.
func (l *Layouts) RenderComponent(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) {
ctx = templ.WithChildren(ctx, component)
component = Component()
if err := component.Render(ctx, w); err != nil {
slog.Error("error while rendering component", "err", err, "ctx", ctx)
}
}
+24 -100
View File
@@ -3,99 +3,29 @@ 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"> <div
@components.Modal(detailsID) { class="grid gap-5 grid-cols-1 lg:hidden m-5"
// Placeholder: only visible until the first image is swapped in. >
// prev/next are nil because relative links would be resolved for _,img := range g.SM.Images {
// against the gallery index, not the image page. <img
if len(g.SM.Images) > 0 { src={ "/images/" + img.UID + "?w=600" }
@GalleryImage(g.SM.Images[0].UID, nil, nil) loading="lazy"
} 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)
// Define a templ script to hold complex post-load behavior </div>
templ GalleryImage(image string, next *gallery.Image, prev *gallery.Image) { <div class="hidden 3xl:block 6xl:hidden m-5">
<link rel="preload" href={ "/images/" + image + "?w=800" } as="image"/> @Gallery(g.LG)
<div class="relative w-10/12 h-10/12 flex items-center justify-center overflow-hidden "> </div>
<!-- 1. Preloaded Base Image (w=800) --> <div class="hidden 6xl:block m-5">
<img @Gallery(g.XLG)
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"
hx-preload="mousedown"
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"
hx-preload="mouseover"
class="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 text-white"
>
&gt;
</button>
}
</div> </div>
} }
@@ -109,18 +39,12 @@ 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"
> >
<button <img
hx-get={ templer.RelativePath(ctx, "./"+img.UID) } src={ "/images/" + img.UID + "?w=800" }
hx-target={ "#" + detailsContentID } loading="lazy"
hx-swap="innerHTML" class="w-full h-full object-cover"
> 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>
+2 -1
View File
@@ -12,7 +12,8 @@ templ Home(pages []page.PageHeader) {
<a <a
class="flex items-center justify-center h-full " class="flex items-center justify-center h-full "
href={ translate.Path(ctx, "/"+page.UID) } href={ translate.Path(ctx, "/"+page.UID) }
hx-preload="mouseover" preload="mouseover"
hx-load-links="true"
> >
<h2 <h2
class="text-3xl font-bold text-white hover:underline text-shadow-lg" class="text-3xl font-bold text-white hover:underline text-shadow-lg"
+48 -61
View File
@@ -1,76 +1,63 @@
/** /**
* @file preload-links.js * @file preload-links.js
* @description HTMX helper to extract <link rel=preload> and image hints from fetched HTML responses. * @description HTMX extension to extract <link rel="preload"> from preloaded HTML responses.
* @generated By opencode (AI Agent) * @generated By opencode (AI Agent)
* @model gemini-3-flash-preview * @model gemini-3-flash-preview
* @date Tue Sep 29 2026 * @date Thu Mar 19 2026
*/ */
(function () { (function () {
const originalFetch = window.fetch; // Save the original XHR methods
const originalOpen = XMLHttpRequest.prototype.open;
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
const originalSend = XMLHttpRequest.prototype.send;
window.fetch = function (input, init) { // We need to track which XHRs are preloads
const responsePromise = originalFetch.apply(this, arguments); XMLHttpRequest.prototype.open = function () {
this._isHtmxPreload = false;
return originalOpen.apply(this, arguments);
};
let method = init?.method; XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
if (!method && input instanceof Request) method = input.method; if (header.toLowerCase() === "hx-preloaded" && value === "true") {
method = (method || "GET").toUpperCase(); this._isHtmxPreload = true;
}
return originalSetRequestHeader.apply(this, arguments);
};
responsePromise XMLHttpRequest.prototype.send = function () {
.then((response) => { if (this._isHtmxPreload) {
const contentType = response.headers.get("content-type") || ""; this.addEventListener("load", function () {
if (method !== "GET" || !contentType.includes("text/html")) return; // Parse the response text as HTML
const parser = new DOMParser();
const doc = parser.parseFromString(this.responseText, "text/html");
return response // Find all preload links
.clone() const links = doc.querySelectorAll('link[rel="preload"]');
.text()
.then((html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll('link[rel="preload"]').forEach((link) => { links.forEach((link) => {
const href = link.getAttribute("href"); const href = link.getAttribute("href");
if (!href) return; // Only inject if the current document doesn't already have it
if ( if (
document.head.querySelector( href &&
`link[rel="preload"][href="${href}"]`, !document.querySelector(`link[rel="preload"][href="${href}"]`)
) ) {
) const newLink = document.createElement("link");
return; newLink.rel = "preload";
newLink.href = href;
const newLink = document.createElement("link"); // Copy relevant attributes
newLink.rel = "preload"; ["as", "type", "media", "fetchpriority", "crossorigin"].forEach(
newLink.href = href; (attr) => {
const val = link.getAttribute(attr);
if (val) newLink.setAttribute(attr, val);
},
);
["as", "type", "media", "fetchpriority", "crossorigin"].forEach( document.head.appendChild(newLink);
(attr) => { }
const val = link.getAttribute(attr); });
if (val) newLink.setAttribute(attr, val); });
}, }
); return originalSend.apply(this, arguments);
document.head.appendChild(newLink);
});
doc.querySelectorAll("img[src]").forEach((img) => {
const href = img.getAttribute("src");
if (!href) return;
if (
document.head.querySelector(
`link[rel="preload"][href="${href}"][as="image"]`,
)
)
return;
const newLink = document.createElement("link");
newLink.rel = "preload";
newLink.as = "image";
newLink.href = href;
document.head.appendChild(newLink);
});
});
})
.catch(() => {});
return responsePromise;
}; };
})(); })();
+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-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 @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
+1
View File
@@ -0,0 +1 @@
(function(){var H=null;function M(){}function a(e,t){if(e&&e.indexOf("<head")>-1){const g=document.createElement("html");var r=e.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,"");var a=r.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);if(a){var n=[];var d=[];var o=[];var i=[];g.innerHTML=a;var h=g.querySelector("head");var m=document.head;if(h==null){return}else{var s=new Map;for(const p of h.children){s.set(p.outerHTML,p)}}var u=H.getAttributeValue(h,"hx-head")||t;for(const x of m.children){var l=s.has(x.outerHTML);var f=x.getAttribute("hx-head")==="re-eval";var v=H.getAttributeValue(x,"hx-preserve")==="true";if(l||v){if(f){d.push(x)}else{s.delete(x.outerHTML);o.push(x)}}else{if(u==="append"){if(f){d.push(x);i.push(x)}}else{if(H.triggerEvent(document.body,"htmx:removingHeadElement",{headElement:x})!==false){d.push(x)}}}}i.push(...s.values());M("to append: ",i);for(const E of i){M("adding: ",E);var c=document.createRange().createContextualFragment(E.outerHTML);M(c);if(H.triggerEvent(document.body,"htmx:addingHeadElement",{headElement:c})!==false){m.appendChild(c);n.push(c)}}for(const b of d){if(H.triggerEvent(document.body,"htmx:removingHeadElement",{headElement:b})!==false){m.removeChild(b)}}H.triggerEvent(document.body,"htmx:afterHeadMerge",{added:n,kept:o,removed:d})}}}htmx.defineExtension("head-support",{init:function(e){H=e;htmx.on("htmx:afterSwap",function(e){let t=e.detail.xhr;if(t){var r=t.response;if(H.triggerEvent(document.body,"htmx:beforeHeadMerge",e.detail)){a(r,e.detail.boosted?"merge":"append")}}});htmx.on("htmx:historyRestore",function(e){if(H.triggerEvent(document.body,"htmx:beforeHeadMerge",e.detail)){if(e.detail.cacheMiss){a(e.detail.serverResponse,"merge")}else{a(e.detail.item.head,"merge")}}});htmx.on("htmx:historyItemCreated",function(e){var t=e.detail.item;t.head=document.head.outerHTML})}})})();
+1 -1
View File
@@ -1 +1 @@
var htmx=(()=>{const e={parse(t){if(!t)return{};if(t.startsWith("{"))return JSON.parse(t);let r=/(?:"([^"]+)"|'([^']+)'|([^\s,:]+))(?:\s*:\s*(?:"([^"]*)"|'([^']*)'|<((?:[^/]|\/(?!>))+)\/>|([^\s,]+)))?(?=\s|,|$)/g,i={};for(let s of t.matchAll(r)){let[,t,r,n,o,a,l,h]=s,c=t??r??n,u=(o??a??l??h??"true").trim();try{u=JSON.parse(u)}catch{}let d=n?.includes("."),f=d?c.split(".").reduceRight((e,t)=>({[t]:e}),u):{[c]:u};e.merge(f,i)}return i},split:e=>e.split(/,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/),merge(t,r){"string"==typeof t&&(t=e.parse(t));for(let[i,s]of Object.entries(t)){if(["__proto__","constructor","prototype"].includes(i))continue;let t=s?.constructor===Object,n=r[i]?.constructor===Object;t&&n?e.merge(s,r[i]):r[i]=s}return r}};class t{#e=null;#t=[];admit(e,t,r){if(!this.#e)return this.#e={strategy:e,abort:r},"run";if("replace"===e||"abort"!==e&&"abort"===this.#e.strategy)return this.#t=[],this.#e.abort?.(),this.#e={strategy:e,abort:r},"run";if("queue all"===e)this.#t.push(t);else if("queue last"===e)this.#t=[t];else{if("abort"===e||"drop"===e||0!==this.#t.length)return"dropped";this.#t.push(t)}return"queued"}continue(){this.#e=null,this.#t.shift()?.()}abort(){this.#e?.abort?.()}}return new class{#r=e;#i=new Map;#s="";#n=new Set;_loc=window.location;#o;#a=Function;#l=Object.getPrototypeOf(async function(){}).constructor;#h={createHTML:e=>e,createScript:e=>e};#c;#u="a,form";#d=["get","post","put","patch","delete","query"];#f;#m;#p;#g;#x;constructor(){this.#b(),this.#y(),this.#c=this.#S("[hx-action],[hx-get],[hx-post],[hx-put],[hx-patch],[hx-delete],[hx-query]"),this.#f=(new XPathEvaluator).createExpression(`.//*[@*[${this.#v("hx-on").map(e=>`starts-with(name(), "${e}")`).join(" or ")}]]`),this.#o={HCON:e,attributeValue:this.#E.bind(this),parseTriggerSpecs:this.#w.bind(this),determineMethodAndAction:this.#A.bind(this),createRequestContext:this.#T.bind(this),collectFormData:this.#C.bind(this),getAttributeObject:this.#q.bind(this),insertContent:this.#N.bind(this),morph:this.#I.bind(this),isSoftMatch:this.#M.bind(this),initSecurity:(e,t,r)=>{e&&(this.#h=e),t&&(this.#a=t),r&&(this.#l=r)},onTrigger:this.#O.bind(this),htmxProp:this.#H.bind(this),triggerHtmxEvent:this.#L.bind(this),executeJavaScript:this.#k.bind(this)};let t=()=>this.initialize();"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):setTimeout(t)}#b(){this.version="4.0.0",this.config={logAll:!1,prefix:"data-hx-",transitions:!1,history:!0,mode:"same-origin",defaultSwap:"innerHTML",defaultFocusScroll:!1,indicatorClass:"htmx-indicator",requestClass:"htmx-request",includeIndicatorCSS:!0,defaultTimeout:6e4,extensions:"",morphIgnore:["data-htmx-powered"],morphSkip:"[hx-morph-skip]",morphSkipChildren:"[hx-morph-skip-children]",morphScanLimit:10,noSwap:[204,304],implicitInheritance:!1,defaultSettleDelay:1,allowEmptySwapAfterOOB:!1};let t=document.querySelector('meta[name="htmx-config"]');t&&e.merge(t.content,this.config),this.#s=this.config.extensions}#y(){if(!1!==this.config.includeIndicatorCSS){let e=this.config.indicatorClass,t=this.config.requestClass,r=new CSSStyleSheet;r.replaceSync(`.${e}{opacity:0;visibility: hidden} .${t} .${e}, .${t}.${e}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`),document.adoptedStyleSheets=[...document.adoptedStyleSheets,r]}}registerExtension(e,t){return!(this.#s&&!this.#s.split(/,\s*/).includes(e))&&(!this.#n.has(e)&&(this.#n.add(e),t.init&&t.init(this.#o),void Object.entries(t).forEach(([e,t])=>{this.#i.get(e)?.push(t)||this.#i.set(e,[t])})))}#V(e){let t=this.config.prefix;return!e.closest||null!=e.closest("[hx-ignore]")||t&&null!=e.closest(`[${t}ignore]`)}#P(e,t){let r=this.config.prefix;return e.getAttribute(t)??(r?e.getAttribute(t.replace("hx-",r)):null)}#R(e,t){let r=this.config.prefix&&t.replace("hx-",this.config.prefix);return e.hasAttribute(t)?t:r&&e.hasAttribute(r)?r:null}#S(e){return this.#v(e).join(",")}#v(e){let t=[e];return this.config.prefix&&t.push(e.replaceAll("hx-",this.config.prefix)),t}#j(e,t){let r=[...e.querySelectorAll?.(t)??[]];return e.matches?.(t)&&r.unshift(e),r}#B(e){return"before"===e?"beforebegin":"after"===e?"afterend":"prepend"===e?"afterbegin":"append"===e?"beforeend":e}#F(e,t){let r=[];return this.#E(e,t,void 0,(e,t)=>{e?.split(/\s*[,:]\s*/).includes("this")&&r.push(t)}),r}#E(e,t,r,i){t=this.#z(t);let s=this.#z(":inherited"),n=this.#z(":append"),o=this.#P(e,t)??this.#P(e,t+s);if(null!=o)return i?i(o,e):o;let a=CSS.escape(this.config.implicitInheritance?t:t+s),l=CSS.escape(t+s+n),h=this.#S(`[${a}],[${l}]`),c=this.#R(e,t+n)??this.#R(e,t+s+n);if(c){let r=e.getAttribute(c),s=e.parentNode?.closest?.(h);if(i&&i(r,e),s){let e=this.#E(s,t,void 0,i);return e?(e+","+r).replace(/[{}]/g,""):r}return r}let u=e.parentNode?.closest?.(h);return u?(o=this.#E(u,t,void 0,i),!i&&o&&this.config.implicitInheritance&&this.#_(e,"htmx:after:implicitInheritance",{elt:e,name:t,parent:u}),o):r}#w(t){return e.split(t).flatMap(t=>{let[,r,i]=t.maLine truncated var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.8"};Q.onLoad=V;Q.process=Ft;Q.on=xe;Q.off=be;Q.trigger=ae;Q.ajax=Ln;Q.find=f;Q.findAll=x;Q.closest=g;Q.remove=_;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=ze;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=j;Q.logNone=$;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:X,findThisElement:Se,filterValues:yn,swap:ze,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:q,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Ee,makeFragment:D,mergeObjects:le,makeSettleInfo:Sn,oobSwap:Te,querySelectorExt:ue,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function y(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function q(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;q(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function A(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function N(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function D(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=A(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);N(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);N(r,i.body);r.title=i.title}else{const i=L('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.protoLine truncated
-1
View File
@@ -1 +0,0 @@
!function(){let e;function t(e){let t=document.createElement(e.tagName);for(const r of e.attributes)t.setAttribute(r.name,r.value);return t.textContent=e.textContent,"LINK"===e.tagName&&"stylesheet"===e.rel?new Promise(e=>{t.onload=e,t.onerror=e,document.head.appendChild(t)}):"SCRIPT"!==e.tagName||!e.src||e.async||e.defer?(document.head.appendChild(t),e._preloadHint&&t.addEventListener("load",()=>e._preloadHint.remove(),{once:!0}),null):new Promise((e,r)=>{t.onload=e,t.onerror=r,document.head.appendChild(t)})}async function r(r,d){if(r&&r.indexOf("<head")>-1){const o=document.createElement("html");let a=r.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,"").match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);if(a){let r=[],n=[],h=[],s=[],m=[];o.innerHTML=a;let l=o.querySelector("head"),i=document.head;if(null==l)return[];let c=new Map;for(const e of l.children)c.set(e.outerHTML,e);let u=e.attributeValue(l,"hx-head")||d;for(const t of i.children){let r=c.has(t.outerHTML),d="re-eval"===t.getAttribute("hx-head"),o="true"===e.attributeValue(t,"hx-preserve");r||o?d?n.push(t):(c.delete(t.outerHTML),h.push(t)):"append"===u?d&&(n.push(t),s.push(t)):!1!==htmx.trigger(document.body,"htmx:head:before:remove",{headElement:t})&&n.push(t)}s.push(...c.values());for(const e of s)if("SCRIPT"===e.tagName&&e.defer){if(m.push(e),e.src){let t=document.createElement("link");t.rel="module"===e.type?"modulepreload":"preload",t.as="script",t.href=e.src,document.head.appendChild(t),e._preloadHint=t}}else!1!==htmx.trigger(document.body,"htmx:head:before:add",{headElement:e})&&(await t(e),r.push(e));for(const e of n)!1!==htmx.trigger(document.body,"htmx:head:before:remove",{headElement:e})&&i.removeChild(e);return htmx.trigger(document.body,"htmx:head:after:merge",{added:r,kept:h,removed:n}),m}}return[]}htmx.registerExtension("hx-head",{init:t=>{e=t},htmx_before_response:(e,t)=>{let d=t.ctx,o=d.target===document.body?"merge":"append";if(htmx.trigger(document.body,"htmx:head:before:merge",t)){let e=d.response.raw.text.bind(d.response.raw);d.response.raw.text=async()=>{let t=await e();return d._deferredHeadScripts=await r(t,o),t}}},htmx_after_swap:(e,r)=>{for(const e of r.ctx._deferredHeadScripts||[])t(e)},htmx_history_cache_before_restore:(e,t)=>{t.head&&(t.ready=r(t.head,"merge").then(e=>{t._deferredHeadScripts=e}))},htmx_history_cache_after_restore:(e,r)=>{for(const e of r._deferredHeadScripts||[])t(e)}})}();
-1
View File
@@ -1 +0,0 @@
(()=>{let e;htmx.registerExtension("preload",{init:t=>{e=t},htmx_after_init:t=>{!function(t){let r=e.attributeValue(t,"hx-preload");if(null==r&&!t._htmx?.boosted)return;let o=[],a=5e3;if(r){let t=e.parseTriggerSpecs(r);if(0===t.length)return;for(const e of t)o.push(e.name),e.timeout&&(a=htmx.parseInterval(e.timeout))}else{let r=t._htmx?.boosted&&"A"===t.tagName,l=null!=e.attributeValue(t,"hx-get");if(!r&&!l)return;if(r&&!1===htmx.config?.preload?.autoBoost)return;htmx.config?.preload?.boostTimeout&&(a=htmx.parseInterval(htmx.config.preload.boostTimeout)),o.push(htmx.config?.preload?.boostEvent||"mousedown"),o.push("touchstart")}let l=async r=>{let{method:o}=e.determineMethodAndAction(t,r);if("GET"!==o)return;if(t._htmx?.preload)return;let l=e.createRequestContext(t,r),n=t.form||t.closest("form"),i=e.collectFormData(t,n,r.submitter),h=e.getAttributeObject(t,"hx-vals",e=>{for(let t in e)i.set(t,e[t])});h&&await h;let s=l.request.action.replace?.(/#.*$/,""),m=new URLSearchParams(i);m.size&&(s+=(/\?/.test(s)?"&":"?")+m);let p=new URL(s,location.href);t._htmx.preload={prefetch:fetch(s,l.request),action:p.origin===location.origin?p.pathname+p.search:p.href,expiresAt:Date.now()+a};try{await t._htmx.preload.prefetch}catch(e){delete t._htmx.preload}};for(let e of o)t.addEventListener(e,l,{passive:!0});t._htmx.preloadListener=l,t._htmx.preloadEvents=o}(t)},htmx_before_request:(e,t)=>{let{ctx:r}=t;if(e._htmx?.preload&&e._htmx.preload.action===r.request.action&&Date.now()<e._htmx.preload.expiresAt){let t=e._htmx.preload.prefetch;r.fetch=()=>t,delete e._htmx.preload}else e._htmx&&delete e._htmx.preload},htmx_before_cleanup:e=>{if(e._htmx?.preloadListener)for(let t of e._htmx.preloadEvents)e.removeEventListener(t,e._htmx.preloadListener)}})})();
+1 -1
View File
@@ -1 +1 @@
(function(){const d=window.fetch;window.fetch=function(o,s){const c=d.apply(this,arguments);let r=s?.method;return!r&&o instanceof Request&&(r=o.method),r=(r||"GET").toUpperCase(),c.then(i=>{const u=i.headers.get("content-type")||"";if(!(r!=="GET"||!u.includes("text/html")))return i.clone().text().then(f=>{const l=new DOMParser().parseFromString(f,"text/html");l.querySelectorAll('link[rel="preload"]').forEach(n=>{const t=n.getAttribute("href");if(!t||document.head.querySelector(`link[rel="preload"][href="${t}"]`))return;const e=document.createElement("link");e.rel="preload",e.href=t,["as","type","media","fetchpriority","crossorigin"].forEach(a=>{const h=n.getAttribute(a);h&&e.setAttribute(a,h)}),document.head.appendChild(e)}),l.querySelectorAll("img[src]").forEach(n=>{const t=n.getAttribute("src");if(!t||document.head.querySelector(`link[rel="preload"][href="${t}"][as="image"]`))return;const e=document.createElement("link");e.rel="preload",e.as="image",e.href=t,document.head.appendChild(e)})})}).catch(()=>{}),c}})(); (function(){const p=XMLHttpRequest.prototype.open,a=XMLHttpRequest.prototype.setRequestHeader,l=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(){return this._isHtmxPreload=!1,p.apply(this,arguments)},XMLHttpRequest.prototype.setRequestHeader=function(r,o){return r.toLowerCase()==="hx-preloaded"&&o==="true"&&(this._isHtmxPreload=!0),a.apply(this,arguments)},XMLHttpRequest.prototype.send=function(){return this._isHtmxPreload&&this.addEventListener("load",function(){new DOMParser().parseFromString(this.responseText,"text/html").querySelectorAll('link[rel="preload"]').forEach(n=>{const t=n.getAttribute("href");if(t&&!document.querySelector(`link[rel="preload"][href="${t}"]`)){const e=document.createElement("link");e.rel="preload",e.href=t,["as","type","media","fetchpriority","crossorigin"].forEach(s=>{const i=n.getAttribute(s);i&&e.setAttribute(s,i)}),document.head.appendChild(e)}})}),l.apply(this,arguments)}})();
+1
View File
@@ -0,0 +1 @@
(function(){htmx.defineExtension("preload",{onEvent:function(e,t){if(e==="htmx:afterProcessNode"){const n=t.target||t.detail.elt;const r=[...n.hasAttribute("preload")?[n]:[],...n.querySelectorAll("[preload]")];r.forEach(function(e){i(e);e.querySelectorAll("[href],[hx-get],[data-hx-get]").forEach(i)});return}if(e==="htmx:beforeRequest"){const o=t.detail.requestConfig.headers;if(!("HX-Preloaded"in o&&o["HX-Preloaded"]==="true")){return}t.preventDefault();const a=t.detail.xhr;a.onload=function(){s(t.detail.elt,a.responseText)};a.onerror=null;a.onabort=null;a.ontimeout=null;a.send()}}});function i(t){if(t.preloadState!==undefined){return}if(!l(t)){return}if(t instanceof HTMLFormElement){const o=t;if(!(o.hasAttribute("method")&&o.method==="get"||o.hasAttribute("hx-get")||o.hasAttribute("hx-data-get"))){return}for(let e=0;e<o.elements.length;e++){const a=o.elements.item(e);i(a);if("labels"in a){a.labels.forEach(i)}}return}let e=p(t,"preload");t.preloadAlways=e&&e.includes("always");if(t.preloadAlways){e=e.replace("always","").trim()}let n=e||"mousedown";const r=n==="mouseover";t.addEventListener(n,u(t,r),{passive:true});if(n==="mousedown"||n==="mouseover"){t.addEventListener("touchstart",u(t),{passive:true})}if(n==="mouseover"){t.addEventListener("mouseout",function(e){if(e.target===t&&t.preloadState==="TIMEOUT"){t.preloadState="READY"}},{passive:true})}t.preloadState="READY";htmx.trigger(t,"preload:init")}function u(t,n=false){return function(){if(t.preloadState!=="READY"){return}if(n){t.preloadState="TIMEOUT";const e=100;window.setTimeout(function(){if(t.preloadState==="TIMEOUT"){t.preloadState="READY";r(t)}},e);return}r(t)}}function r(n){if(n.preloadState!=="READY"){return}n.preloadState="LOADING";const e=n.getAttribute("hx-get")||n.getAttribute("data-hx-get");if(e){m(e,n);return}const t=p(n,"hx-boost")==="true";if(n.hasAttribute("href")){const r=n.getAttribute("href");if(t){m(r,n)}else{h(r,n)}return}if(g(n)){const r=n.form.getAttribute("action")||n.form.getAttribute("hx-get")||n.form.getAttribute("data-hx-get");const o=htmx.values(n.form);const a=!(n.form.getAttribute("hx-get")||n.form.getAttribute("data-hx-get")||t);const i=a?h:m;if(n.type==="submit"){i(r,n.form,o);return}const u=n.name||n.control.name;if(n.tagName==="SELECT"){Array.from(n.options).forEach(e=>{if(e.selected)return;o.set(u,e.value);const t=d(n.form,o);i(r,n.form,t)});return}const s=n.getAttribute("type")||n.control.getAttribute("type");const l=n.value||n.control?.value;if(s==="radio"){o.set(u,l)}else if(s==="checkbox"){const c=o.getAll(u);if(c.includes(l)){o[u]=c.filter(e=>e!==l)}else{o.append(u,l)}}const f=d(n.form,o);i(r,n.form,f);return}}function d(e,t){const n=e.elements;const r=new FormData;for(let e=0;e<n.length;e++){const o=n.item(e);if(t.has(o.name)&&o.tagName==="SELECT"){r.append(o.name,t.get(o.name));continue}if(t.has(o.name)&&t.getAll(o.name).includes(o.value)){r.append(o.name,o.value)}}return r}function m(e,t,n=undefined){htmx.ajax("GET",e,{source:t,values:n,headers:{"HX-Preloaded":"true"}})}function h(e,t,n=undefined){const r=new XMLHttpRequest;if(n){e+="?"+new URLSearchParams(n.entries()).toString()}r.open("GET",e);r.setRequestHeader("HX-Preloaded","true");r.onload=function(){s(t,r.responseText)};r.send()}function s(e,t){e.preloadState=e.preloadAlways?"READY":"DONE";if(p(e,"preload-images")==="true"){document.createElement("div").innerHTML=t}}function p(e,t){if(e==undefined){return undefined}return e.getAttribute(t)||e.getAttribute("data-"+t)||p(e.parentElement,t)}function l(e){const n=["href","hx-get","data-hx-get"];const t=t=>n.some(e=>t.hasAttribute(e))||t.method==="get";const r=e.form instanceof HTMLFormElement&&t(e.form)&&g(e);if(!t(e)&&!r){return false}if(e instanceof HTMLInputElement&&e.closest("label")){return false}return true}function g(e){if(e instanceof HTMLInputElement||e instanceof HTMLButtonElement){const t=e.getAttribute("type");return["checkbox","radio","submit"].includes(t)}if(e instanceof HTMLLabelElement){return e.control&&g(e.control)}return e instanceof HTMLSelectElement}})();