Compare commits

...
15 Commits
Author SHA1 Message Date
schreifuchs 2a96034fca chore(htmx): upgrade htmx to v4
release.yaml / publish (push) Successful in 3m48s
2026-09-29 11:03:38 +02:00
schreifuchs 812891a205 fix(gallery): use component template
release.yaml / publish (push) Successful in 3m51s
2026-09-29 10:46:45 +02:00
schreifuchs 297efaa4a6 fix(translations): stay on page
release.yaml / publish (push) Successful in 4m0s
2026-09-28 09:52:22 +02:00
schreifuchs c08dd355c0 fix(gallery): faster image loading
release.yaml / publish (push) Successful in 3m56s
2026-09-26 23:35:15 +02:00
schreifuchs 9ec2585030 feat(gallery): image detail view
release.yaml / publish (push) Successful in 4m0s
2026-09-26 19:28:00 +02:00
schreifuchs 72bc974aa6 feat(gallery): preload images
release.yaml / publish (push) Successful in 3m4s
2026-09-10 14:02:05 +02:00
schreifuchs c678c7aa45 fix(gallery): use correct aspect ratios
/ publish (push) Successful in 3m48s
2026-06-08 11:18:44 +02:00
schreifuchs 763163c24e feat: image gallery (#2)
/ publish (push) Successful in 4m4s
Reviewed-on: #2
2026-06-01 14:07:50 +02:00
schreifuchs ee3eba3cd9 feat(images): cache lifetime in config
/ publish (push) Successful in 3m43s
2026-03-19 22:10:20 +01:00
schreifuchs b384c70c39 feat: preload images 2026-03-19 22:07:02 +01:00
schreifuchs 1c67eb0cff chore: update readme 2026-03-19 18:30:42 +01:00
schreifuchs 16e5107185 feat: title on pages 2026-03-19 18:27:23 +01:00
schreifuchs 2b07701762 feat: better caching headers for images 2026-03-09 13:35:23 +01:00
schreifuchs a27b4a66e6 chore: use json logger 2026-03-09 09:23:31 +01:00
schreifuchs bc900e1450 chore: use better scaling kernel 2026-03-09 08:41:52 +01:00
70 changed files with 1756 additions and 2206 deletions

No files matched your search

+8
View File
@@ -27,3 +27,11 @@ 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 -7
View File
@@ -17,9 +17,6 @@ RUN pnpm run build:css && pnpm run build:js
FROM golang:1.26-alpine AS go-builder FROM golang:1.26-alpine AS go-builder
WORKDIR /app WORKDIR /app
# Install git for potential private modules (though not strictly needed here)
RUN apk add --no-cache git
# Download Go modules # Download Go modules
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -29,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/htmx.min.js ./web/static/js/ COPY --from=frontend-builder /app/web/static/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
@@ -41,9 +38,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/schr
FROM alpine:latest FROM alpine:latest
WORKDIR /app WORKDIR /app
# Install root certificates and timezone data
RUN apk --no-cache add ca-certificates tzdata
# Copy the compiled binary from the builder stage # Copy the compiled binary from the builder stage
COPY --from=go-builder /app/server . COPY --from=go-builder /app/server .
+7
View File
@@ -0,0 +1,7 @@
.PHONY: run
run: docker
air
.PHONY: docker
docker:
docker compose up -d
+36 -19
View File
@@ -2,35 +2,40 @@
Personal portfolio website for Schreifuchs, featuring photography, informatics, music, and video. Personal portfolio website for Schreifuchs, featuring photography, informatics, music, and video.
The site is built with **Svelte 5** and **SvelteKit**, and it is powered by a dynamic **Nextcloud** backend via WebDAV. The site is built with **Go**, **templ**, and **HTMX**, and it is powered by a dynamic **Nextcloud** backend via WebDAV.
## Features ## Features
- **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud. - **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud.
- **Hidden Sites:** Folders starting with `_` are excluded from the navigation menu but remain accessible via direct links. - **Hidden Sites:** Folders starting with `_` are excluded from the navigation menu but remain accessible via direct links.
- **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file. - **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file and rendered via `gomarkdown`.
- **Image Optimization:** Automatic image resizing via Nextcloud's Preview API for optimized delivery and `srcset` support. - **Image Optimization:** Automatic image resizing via Nextcloud's Preview API for optimized delivery and `srcset` support.
- **Asset Proxying:** Images and attachments are securely streamed from Nextcloud through a server-side proxy. - **Asset Proxying:** Images and attachments are securely streamed from Nextcloud through a server-side proxy.
- **Performance:** In-memory caching for Nextcloud requests (15-minute TTL). - **Performance:** Multi-layer caching using **Valkey** (Redis-compatible) for WebDAV requests and image scaling.
- **Responsive Design:** Styled with Tailwind CSS, optimized for all screen sizes. - **Responsive Design:** Styled with **Tailwind CSS**, optimized for all screen sizes.
## Tech Stack ## Tech Stack
- **Framework:** Svelte 5 (Runes) & SvelteKit - **Backend:** Go (Golang)
- **UI Components:** [templ](https://templ.guide/)
- **Frontend Interactivity:** [HTMX](https://htmx.org/)
- **Styling:** Tailwind CSS - **Styling:** Tailwind CSS
- **Backend:** Node.js (via `@sveltejs/adapter-node`) - **Caching:** Valkey
- **Integration:** `webdav` library for Nextcloud communication - **Integration:** `gowebdav` for Nextcloud communication
- **Content:** `@ts-stack/markdown` for rendering - **Content:** `github.com/gomarkdown/markdown` for rendering
## Configuration ## Configuration
The application requires the following environment variables in a `.env` file: The application requires the following environment variables in a `.env` file:
```env ```env
NEXTCLOUD_URL=https://your-nextcloud-instance.com FILESYSTEM_URL=https://your-nextcloud-instance.com/remote.php/dav/files/user/
NEXTCLOUD_USER=your_username FILESYSTEM_USERNAME=your_username
NEXTCLOUD_PASSWORD=your_app_password FILESYSTEM_PASSWORD=your_app_password
NEXTCLOUD_BASE_DIR=WebsiteContent # Optional: Root folder for site content CACHE_ADDRESS=localhost:6379
CACHE_PASSWORD=your_valkey_password
PORT=8080
LOG_LEVEL=DEBUG
``` ```
## Content Management ## Content Management
@@ -45,28 +50,40 @@ To add or update content, manage files in your configured Nextcloud directory:
## Development ## Development
Install dependencies: Install dependencies (Go, pnpm, and the `templ` binary):
```bash ```bash
# Install Go dependencies
go mod tidy
# Install JS/CSS dependencies
pnpm install pnpm install
``` ```
Start the development server: Start the development server with hot reloading:
```bash ```bash
pnpm run dev air
```
For CSS changes, run in a separate terminal:
```bash
pnpm run watch:css
``` ```
## Building ## Building
Build for production (Node.js): Build the project for production:
```bash ```bash
pnpm run build # Generate Go code from Templ files
go generate ./...
# Build CSS
pnpm run build:css
# Build Go binary
go build -o ./tmp/main ./cmd/schreifuchs-ch/main.go
``` ```
The output will be in the `build/` directory, ready to be served by Node.
## Image Processing ## Image Processing
To optimize images before uploading to Nextcloud: To optimize images before uploading to Nextcloud:
+22 -7
View File
@@ -3,20 +3,14 @@ package main
import ( import (
"context" "context"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
_ "net/http/pprof"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/server" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/server"
) )
func main() { func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
ctx := context.Background() ctx := context.Background()
ctx, cancle := context.WithCancel(ctx) ctx, cancle := context.WithCancel(ctx)
defer cancle() defer cancle()
@@ -29,16 +23,37 @@ func main() {
cancle() cancle()
}() }()
slog.SetLogLoggerLevel(slog.LevelDebug) slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})))
cfg, err := config.Read(ctx) cfg, err := config.Read(ctx)
if err != nil { if err != nil {
slog.Error("could not read configuaration", "err", err) slog.Error("could not read configuaration", "err", err)
} }
setupLogger(&cfg)
err = server.Start(ctx, cfg) err = server.Start(ctx, cfg)
if err != nil { if err != nil {
slog.Error("error while serving", "err", err) slog.Error("error while serving", "err", err)
return return
} }
} }
func setupLogger(cfg *config.Cfg) {
opts := slog.HandlerOptions{
Level: cfg.LogLevel,
}
var handler slog.Handler
if cfg.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &opts)
} else {
handler = slog.NewTextHandler(os.Stdout, &opts)
}
slog.SetDefault(
slog.New(
handler,
),
)
}
-1
View File
@@ -1,7 +1,6 @@
services: services:
valkey: valkey:
image: valkey/valkey:8 image: valkey/valkey:8
restart: always
ports: ports:
- "6379:6379" - "6379:6379"
command: valkey-server --requirepass "valkey" command: valkey-server --requirepass "valkey"
+6 -3
View File
@@ -5,13 +5,17 @@ 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
github.com/sethvargo/go-envconfig v1.3.0 github.com/sethvargo/go-envconfig v1.3.0
github.com/studio-b12/gowebdav v0.12.0 github.com/studio-b12/gowebdav v0.12.0
github.com/valkey-io/valkey-go v1.0.72 github.com/valkey-io/valkey-go v1.0.72
github.com/valkey-io/valkey-go/mock v1.0.72
go.uber.org/mock v0.6.0
golang.org/x/image v0.36.0 golang.org/x/image v0.36.0
golang.org/x/sync v0.16.0
) )
require ( require (
@@ -25,9 +29,8 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/natefinch/atomic v1.0.1 // indirect github.com/natefinch/atomic v1.0.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/mod v0.26.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.35.0 // indirect golang.org/x/tools v0.36.0 // indirect
) )
+10 -4
View File
@@ -1,3 +1,5 @@
github.com/InfinityTools/go-binpack2d v1.0.0 h1:l15wC3zDlR0FoffIcPze0qbszshmGCpMncDiAxpWnE8=
github.com/InfinityTools/go-binpack2d v1.0.0/go.mod h1:3hwEmVO6ffChGwW55BVr9k4rzSYGow0OH2B8bjEdO6o=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
@@ -39,14 +41,18 @@ github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOV
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/valkey-io/valkey-go v1.0.72 h1:iRWt1hJyOchcEgbHSkRY3aKkcBudxvMaVMsmxuYxuxE= github.com/valkey-io/valkey-go v1.0.72 h1:iRWt1hJyOchcEgbHSkRY3aKkcBudxvMaVMsmxuYxuxE=
github.com/valkey-io/valkey-go v1.0.72/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k= github.com/valkey-io/valkey-go v1.0.72/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k=
github.com/valkey-io/valkey-go/mock v1.0.72 h1:rE8K/sjlX0SRldI70Rt4/MCrYl224XD4A4vkYegP1Iw=
github.com/valkey-io/valkey-go/mock v1.0.72/go.mod h1:A4B8L3Wg85yAOl/GwNgkO/6aeGNXydwBl+86e20NQQY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
@@ -57,7 +63,7 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+125
View File
@@ -0,0 +1,125 @@
package gallery
import (
"context"
"log/slog"
"math"
"github.com/InfinityTools/go-binpack2d"
)
type Gallery struct {
GridWith int
GridHeight int
Images []Image
}
type Set struct {
SM Gallery
LG Gallery
XLG Gallery
}
func (s *Service) GetGallerySet(ctx context.Context) (gallerySet Set, err error) {
gallerySet.SM, err = s.getGallery(ctx, 50)
if err != nil {
return
}
gallerySet.LG, err = s.getGallery(ctx, 80)
if err != nil {
return
}
gallerySet.XLG, err = s.getGallery(ctx, 120)
if err != nil {
return
}
return
}
func (s *Service) getGallery(ctx context.Context, width int) (gallery Gallery, err error) {
imgs, err := s.GetImages(ctx)
if err != nil {
return
}
packer := binpack2d.Create(width, math.MaxInt)
for len(imgs) > 0 {
unused := make([]Image, 0, len(imgs))
for _, img := range imgs {
adjustImageSizeStrictRatio(&img, 200)
if img.Height == 0 || img.Width == 0 {
slog.Warn("image has invalid size", "img", img)
continue
}
rect, ok := packer.Insert(img.Width, img.Height, binpack2d.RULE_BEST_SHORT_SIDE_FIT)
if !ok {
unused = append(unused, img)
continue
}
img.X = rect.X
img.Y = rect.Y
gallery.Images = append(gallery.Images, img)
}
imgs = unused
}
packer.ShrinkBin(false)
gallery.GridHeight = packer.GetHeight()
gallery.GridWith = packer.GetWidth()
return
}
// getCleanRatio returns the simplified aspect ratio as (numerator, denominator).
// It searches for the best fit within a denominator limit to account for rounding errors.
func getCleanRatio(width, height int) (int, int) {
targetRatio := float64(width) / float64(height)
const maxDenominator = 100
bestN, bestD := 0, 1
minDiff := math.MaxFloat64
for d := 1; d <= maxDenominator; d++ {
// Calculate the closest numerator for this denominator
n := int(math.Round(float64(d) * targetRatio))
// Calculate the difference
currentRatio := float64(n) / float64(d)
diff := math.Abs(currentRatio - targetRatio)
// Keep track of the one with the smallest error
if diff < minDiff {
minDiff = diff
bestN, bestD = n, d
}
}
return bestN, bestD
}
func adjustImageSizeStrictRatio(img *Image, targetArea int) {
baseW, baseH := getCleanRatio(img.Width, img.Height)
baseArea := baseW * baseH
idealK := math.Sqrt(float64(targetArea) / float64(baseArea))
k := int(math.Floor(idealK))
if math.Abs(float64(k*k*baseArea-targetArea)) >
math.Abs(float64(int(math.Ceil(idealK))*int(math.Ceil(idealK))*baseArea-targetArea)) {
k = int(math.Ceil(idealK))
}
slog.Debug("adslkfj", "k", k, "idealK", idealK, "baseH", baseH, "baseW", baseW, "imgW", img.Width, "imgH", img.Height)
// 5. Apply the scaled dimensions (guarantees perfect aspect ratio)
img.Width = baseW * k
img.Height = baseH * k
}
+40
View File
@@ -0,0 +1,40 @@
package gallery
import (
"os"
"path"
)
type fileWithPath struct {
os.FileInfo
path string
}
func (f fileWithPath) Name() string {
return path.Join(f.path, f.FileInfo.Name())
}
func (s *Service) readDirRecursive(path string) (files []os.FileInfo, err error) {
return s.readDirRecursiveTo([]os.FileInfo{}, path)
}
func (s *Service) readDirRecursiveTo(files []os.FileInfo, p ...string) ([]os.FileInfo, error) {
localFiles, err := s.fs.ReadDir(path.Join(p...))
if err != nil {
return files, err
}
for _, file := range localFiles {
if file.IsDir() {
newP := append(p, file.Name())
files, err = s.readDirRecursiveTo(files, newP...)
if err != nil {
return files, err
}
} else {
files = append(files, fileWithPath{file, path.Join(p[1:]...)})
}
}
return files, nil
}
+156
View File
@@ -0,0 +1,156 @@
package gallery
import (
"bytes"
"context"
"fmt"
"hash/fnv"
"log/slog"
"os"
"path"
"strings"
"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"
)
type Image struct {
UID string
X int
Y int
Width int
Height int
}
func (i Image) URL() string {
return images.URLFromUID(i.UID)
}
const (
imagesKey = "gallery:images:list"
hashKey = "gallery:images:hash"
)
func (s *Service) autoRefresh() {
t := time.NewTicker(time.Minute)
for {
ctx, cancle := context.WithTimeout(context.Background(), time.Minute)
if err := s.assembleImageList(ctx); err != nil {
slog.Error("error while refreshing gallery", "err", err)
}
cancle()
<-t.C
}
}
func (s *Service) assembleImageList(ctx context.Context) error {
files, err := s.readDirRecursive(s.cfg.Path)
if err != nil {
return fmt.Errorf("could not read contents of %s: %w", s.cfg.Path, err)
}
savedHash, _ := s.cache.Do(ctx, s.cache.B().Get().Key(hashKey).Build()).AsBytes()
newHash := hashImageList(files)
if bytes.Equal(savedHash, newHash) {
slog.Debug("images up to date no refreshing of gallery", "savedHash", savedHash, "newHash", newHash)
return nil
}
slog.Info("starting gallery refresh")
defer slog.Info("gallery refresh completed")
imgs := make([]Image, 0, len(files))
for _, file := range files {
if strings.HasSuffix(file.Name(), ".md") {
continue
}
imagePath := path.Join(s.cfg.Path, file.Name())
img, err := s.img.GetImage(imagePath)
if err != nil || img.Image == nil {
slog.Warn("could not get image for gallery", "err", err, "img", img)
continue
}
uid, err := img.UID()
if err != nil {
continue
}
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{
UID: uid,
Width: w,
Height: h,
})
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(hashKey).Value(valkey.BinaryString(newHash)).Build()).Error(); err != nil {
return err
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(imagesKey).Value(valkey.JSON(imgs)).Build()).Error(); err != nil {
return err
}
return nil
}
func hashImageList(files []os.FileInfo) []byte {
h := fnv.New128()
for _, file := range files {
fmt.Fprint(h, file.ModTime())
fmt.Fprint(h, file.Size())
fmt.Fprint(h, file.Name())
}
return h.Sum([]byte{})
}
func (s *Service) GetImages(ctx context.Context) (images []Image, err error) {
err = s.cache.Do(ctx, s.cache.B().Get().Key(imagesKey).Build()).DecodeJSON(&images)
return
}
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",
}
}
+35
View File
@@ -0,0 +1,35 @@
package gallery
import (
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"github.com/valkey-io/valkey-go"
)
type getImager interface {
GetImage(path string) (img images.Image, err error)
}
type Service struct {
fs filesystem.FS
cfg *config.Gallery
img getImager
cache valkey.Client
refreshIntervall time.Duration
}
func New(fs filesystem.FS, cfg *config.Gallery, img getImager, cache valkey.Client) *Service {
s := &Service{
fs: fs,
cfg: cfg,
img: img,
cache: cache,
}
go s.autoRefresh()
return s
}
+24 -8
View File
@@ -13,7 +13,6 @@ import (
_ "image/png" _ "image/png"
"io" "io"
"strings" "strings"
"time"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
@@ -22,7 +21,7 @@ import (
var ErrNotAnImage = errors.New("not an image") var ErrNotAnImage = errors.New("not an image")
func (s *Service) getMime(path string) (mimeType string, err error) { func (s *Service) GetMime(path string) (mimeType string, err error) {
info, err := s.fs.Stat(path) info, err := s.fs.Stat(path)
if err != nil { if err != nil {
err = fmt.Errorf("image file info could not be fetched: %w", err) err = fmt.Errorf("image file info could not be fetched: %w", err)
@@ -30,18 +29,18 @@ func (s *Service) getMime(path string) (mimeType string, err error) {
if mimer, ok := info.(interface{ ContentType() string }); ok { if mimer, ok := info.(interface{ ContentType() string }); ok {
mimeType = mimer.ContentType() mimeType = mimer.ContentType()
return
} }
return return
} }
func (s *Service) getImage(uid string) (file []byte, mimeType string, err error) { func (s *Service) getImage(path string) (file []byte, mimeType string, err error) {
path, err := PathFromUID(uid)
if err != nil { if err != nil {
err = fmt.Errorf("could not get path: %w", err) err = fmt.Errorf("could not get path: %w", err)
return return
} }
mimeType, err = s.getMime(path) mimeType, err = s.GetMime(path)
if err != nil { if err != nil {
return return
} }
@@ -59,7 +58,19 @@ func (s *Service) getImage(uid string) (file []byte, mimeType string, err error)
return return
} }
func (s *Service) GetImage(ctx context.Context, uid string, options Options) (img io.Reader, mimeType string, err error) { func (s *Service) GetImage(path string) (img Image, err error) {
rawImage, _, err := s.getImage(path)
if err != nil {
return
}
img.Path = path
img.Image, _, err = image.Decode(bytes.NewBuffer(rawImage))
return
}
func (s *Service) GetScaledImage(ctx context.Context, uid string, options Options) (img io.Reader, mimeType string, err error) {
if err = s.seph.Acquire(ctx, 1); err != nil { if err = s.seph.Acquire(ctx, 1); err != nil {
err = fmt.Errorf("could not Acquire semaphore: %w", err) err = fmt.Errorf("could not Acquire semaphore: %w", err)
return return
@@ -67,7 +78,12 @@ func (s *Service) GetImage(ctx context.Context, uid string, options Options) (im
} }
defer s.seph.Release(1) defer s.seph.Release(1)
rawImage, mimeType, err := s.getImage(uid) path, err := PathFromUID(uid)
if err != nil {
return
}
rawImage, mimeType, err := s.getImage(path)
if err != nil { if err != nil {
return return
} }
@@ -97,7 +113,7 @@ func (s *Service) GetImage(ctx context.Context, uid string, options Options) (im
if err != nil { if err != nil {
return return
} }
if err = s.cache.Do(ctx, s.cache.B().Set().Key("img:"+hash).Value(valkey.BinaryString(outBuff.Bytes())).Ex(time.Hour*48).Build()).Error(); err == nil { if err = s.cache.Do(ctx, s.cache.B().Set().Key("img:"+hash).Value(valkey.BinaryString(outBuff.Bytes())).Ex(s.cfg.CacheLifetime).Build()).Error(); err == nil {
return outBuff, "image/jpeg", err return outBuff, "image/jpeg", err
} }
@@ -0,0 +1,37 @@
package images_test
import (
"context"
"errors"
"testing"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/test/testdata"
"github.com/valkey-io/valkey-go"
"github.com/valkey-io/valkey-go/mock"
"go.uber.org/mock/gomock"
)
func Setup(ctx context.Context, ctrl *gomock.Controller) (*images.Service, *mock.Client) {
valkey := mock.NewClient(ctrl)
return images.New(testdata.FS(), &config.Image{Concurency: 1, Quality: 80}, valkey), valkey
}
func BenchmarkService_GetImage(b *testing.B) {
srv, valkeyClient := Setup(b.Context(), gomock.NewController(b))
uid, err := images.UIDFromPath("cover.jpg")
if err != nil {
b.Error("could not get uid", err)
}
b.ReportAllocs()
for b.Loop() {
valkeyClient.EXPECT().Do(gomock.Any(), gomock.Any()).Return(mock.ErrorResult(errors.New("adf")))
valkeyClient.EXPECT().Do(gomock.Any(), gomock.Any()).Return(mock.Result(valkey.ValkeyMessage{}))
_, _, err = srv.GetScaledImage(b.Context(), uid, images.Options{Width: 500, Quality: 80})
if err != nil {
b.Error(err)
}
}
}
+19 -7
View File
@@ -9,6 +9,11 @@ import (
"strings" "strings"
) )
const (
sourceSetSteps = 300
sourceSetMax = 1500
)
func UIDFromPath(path string) (uid string, err error) { func UIDFromPath(path string) (uid string, err error) {
var b bytes.Buffer var b bytes.Buffer
// NewWriter with NoDict (nil) creates a raw DEFLATE compressor // NewWriter with NoDict (nil) creates a raw DEFLATE compressor
@@ -28,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
} }
@@ -57,12 +67,14 @@ func PathFromUID(uid string) (string, error) {
func SourceSet(src string) string { func SourceSet(src string) string {
sb := strings.Builder{} sb := strings.Builder{}
for i := range 19 { i := 1
sb.WriteString(fmt.Sprintf("%s?w=%d %dw, ", src, 100*(i+1), 100*(i+1))) for {
} width := i * sourceSetSteps
sb.WriteString(fmt.Sprintf("%s?w=%d %dw, ", src, width, width))
i := 20 if width >= sourceSetMax {
sb.WriteString(fmt.Sprintf("%s?w=%d %dw", src, 100*(i+1), 100*(i+1)))
return sb.String() return sb.String()
} }
sb.WriteRune(',')
i++
}
}
+12
View File
@@ -0,0 +1,12 @@
package images
import "image"
type Image struct {
image.Image
Path string `json:"path"`
}
func (i Image) UID() (string, error) {
return UIDFromPath(i.Path)
}
+2 -2
View File
@@ -18,10 +18,10 @@ type Service struct {
fs fileSystem fs fileSystem
cache valkey.Client cache valkey.Client
seph *semaphore.Weighted seph *semaphore.Weighted
cfg *config.ImageConfig cfg *config.Image
} }
func New(fs fileSystem, cfg *config.ImageConfig, cache valkey.Client) *Service { func New(fs fileSystem, cfg *config.Image, cache valkey.Client) *Service {
return &Service{ return &Service{
fs: fs, fs: fs,
cache: cache, cache: cache,
+1 -1
View File
@@ -14,7 +14,7 @@ func scale(src image.Image, options Options) image.Image {
) )
// Resize: // Resize:
draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) draw.ApproxBiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
// Encode to `output`: // Encode to `output`:
return dst return dst
+3 -1
View File
@@ -16,6 +16,7 @@ type PageHeader struct {
type Page struct { type Page struct {
PageHeader PageHeader
Content string Content string
Images []Image
} }
func (s *Service) GetPages(ctx context.Context) (pages []PageHeader, err error) { func (s *Service) GetPages(ctx context.Context) (pages []PageHeader, err error) {
@@ -46,7 +47,7 @@ func (s *Service) GetPage(ctx context.Context, uid string) (page Page, err error
return return
} }
html, err := s.getHTML(ctx, p) html, imgs, err := s.getHTML(ctx, p)
if err != nil { if err != nil {
return return
} }
@@ -54,6 +55,7 @@ func (s *Service) GetPage(ctx context.Context, uid string) (page Page, err error
page = Page{ page = Page{
PageHeader: p, PageHeader: p,
Content: string(html), Content: string(html),
Images: imgs,
} }
return return
} }
+17 -5
View File
@@ -14,7 +14,13 @@ import (
"github.com/gomarkdown/markdown/parser" "github.com/gomarkdown/markdown/parser"
) )
func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err error) { // image can be used to preload
type Image struct {
SrcSet string
Src string
}
func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, imgs []Image, err error) {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions) p := parser.NewWithExtensions(extensions)
@@ -24,7 +30,7 @@ func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err
htmlFlags := html.CommonFlags | html.HrefTargetBlank htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{ opts := html.RendererOptions{
Flags: htmlFlags, Flags: htmlFlags,
RenderNodeHook: imageMiddleware(page), RenderNodeHook: imageMiddleware(page, &imgs),
} }
renderer := html.NewRenderer(opts) renderer := html.NewRenderer(opts)
@@ -32,7 +38,7 @@ func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err
return return
} }
func imageMiddleware(page PageHeader) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { func imageMiddleware(page PageHeader, imgs *[]Image) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
if !entering { if !entering {
return ast.GoToNext, false return ast.GoToNext, false
@@ -53,14 +59,20 @@ func imageMiddleware(page PageHeader) func(w io.Writer, node ast.Node, entering
slog.Error("could not get image url", "err", err) slog.Error("could not get image url", "err", err)
return ast.GoToNext, false return ast.GoToNext, false
} }
image := Image{
SrcSet: images.SourceSet(src),
Src: src + "?w=1024",
}
img.Attribute = &ast.Attribute{Attrs: map[string][]byte{ img.Attribute = &ast.Attribute{Attrs: map[string][]byte{
"srcset": []byte(images.SourceSet(src)), // "srcset": []byte(image.SrcSet),
"fetchpriority": []byte("low"), "fetchpriority": []byte("low"),
"loading": []byte("lazy"), "loading": []byte("lazy"),
"class": []byte("min-h-48"), "class": []byte("min-h-48"),
"src": []byte(src), "src": []byte(image.Src),
}} }}
*imgs = append(*imgs, image)
// // img.Attrs["srcset"] = // // img.Attrs["srcset"] =
} }
+5
View File
@@ -56,10 +56,15 @@ func (s *Service) getPageHeaders(ctx context.Context, uid string) (page PageHead
} }
} }
if contentMD != nil {
page.md, err = s.fs.Read(contentMD.Path()) page.md, err = s.fs.Read(contentMD.Path())
if err != nil { if err != nil {
return return
} }
} else {
page.md = []byte("# ...")
}
title := titleRGX.FindStringSubmatch(string(page.md)) title := titleRGX.FindStringSubmatch(string(page.md))
if len(title) < 2 { if len(title) < 2 {
+9 -1
View File
@@ -25,6 +25,7 @@ func SupportedLanguages(ctx context.Context) []string {
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 getting supported languages") slog.Error("could not extract translation service from context for getting supported languages")
return []string{}
} }
return s.SupportedLanguages() return s.SupportedLanguages()
} }
@@ -52,14 +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.Info("path for lang", "path", pp) 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:]
} }
@@ -1,4 +1,4 @@
package restutil package rest
import ( import (
"encoding/json" "encoding/json"
@@ -1,4 +1,4 @@
package restutil package rest
import ( import (
"fmt" "fmt"
+13
View File
@@ -0,0 +1,13 @@
package rest
import "net/http"
type Middeware = func(next http.Handler) http.Handler
func Use(handler http.Handler, middlewares ...Middeware) http.Handler {
for _, middleware := range middlewares {
handler = middleware(handler)
}
return handler
}
@@ -0,0 +1,45 @@
package restgallery
import (
"fmt"
"log/slog"
"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"
)
const preloadCount = 6
func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) {
imgs, err := h.srv.GetGallerySet(r.Context())
if err != nil {
slog.Error("could not get gallery set", "err", err)
rest.SendErr(w, err)
return
}
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.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))
}
+45
View File
@@ -0,0 +1,45 @@
package restgallery
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"github.com/a-h/templ"
)
type Handler struct {
http.Handler
srv galleryService
r renderer
cfg *config.Gallery
}
func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
mux := http.NewServeMux()
h := &Handler{
Handler: mux,
srv: srv,
r: renderer,
cfg: cfg,
}
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
}
type galleryService interface {
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 {
RenderPage(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)
}
+20 -2
View File
@@ -1,8 +1,12 @@
package resthome package resthome
import ( import (
"context"
"errors"
"fmt"
"net/http" "net/http"
"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"
) )
@@ -13,17 +17,31 @@ func (h *Handler) home(w http.ResponseWriter, r *http.Request) {
return return
} }
h.r.Render(r.Context(), w, r, pages.Home(pageHeaders)) h.r.RenderPage(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) {
ctx := r.Context()
uid := r.PathValue("uid") uid := r.PathValue("uid")
page, err := h.src.GetPage(r.Context(), uid) page, err := h.src.GetPage(r.Context(), uid)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
w.WriteHeader(http.StatusRequestTimeout)
}
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
h.r.Render(r.Context(), w, r, pages.ContentPage(page)) for i, image := range page.Images {
if i > 3 {
break
}
ctx = templer.AppendHeader(ctx, fmt.Sprintf(`<link rel="preload" href="%s" as="image" />`, image.Src))
}
ctx = templer.PageTitle(ctx, page.Title)
h.r.RenderPage(ctx, w, r, pages.ContentPage(page))
} }
+5 -2
View File
@@ -10,12 +10,15 @@ import (
) )
type Handler struct { type Handler struct {
http.Handler
src pageService src pageService
r renderer r renderer
} }
func New(mux *http.ServeMux, renderer renderer, srv pageService) *Handler { func New(renderer renderer, srv pageService) *Handler {
mux := http.NewServeMux()
h := &Handler{ h := &Handler{
Handler: mux,
src: srv, src: srv,
r: renderer, r: renderer,
} }
@@ -30,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 {
Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) RenderPage(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
} }
@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restutil" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
) )
func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) { func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
@@ -14,24 +14,24 @@ func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
var err error var err error
options := images.Options{} options := images.Options{}
options.Height, err = restutil.IntParam(r, "h") options.Height, err = rest.IntParam(r, "h")
if err != nil { if err != nil {
restutil.SendErr(w, err) rest.SendErr(w, err)
return return
} }
options.Width, err = restutil.IntParam(r, "w") options.Width, err = rest.IntParam(r, "w")
if err != nil { if err != nil {
restutil.SendErr(w, err) rest.SendErr(w, err)
return return
} }
options.Quality, err = restutil.IntParam(r, "q") options.Quality, err = rest.IntParam(r, "q")
if err != nil { if err != nil {
restutil.SendErr(w, err) rest.SendErr(w, err)
return return
} }
img, mime, err := h.img.GetImage(r.Context(), uid, options) img, mime, err := h.img.GetScaledImage(r.Context(), uid, options)
if err != nil { if err != nil {
slog.Error("error wile serving image", "err", err, "uid", uid) slog.Error("error wile serving image", "err", err, "uid", uid)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
+5 -2
View File
@@ -9,11 +9,14 @@ import (
) )
type Handler struct { type Handler struct {
http.Handler
img imageService img imageService
} }
func New(mux *http.ServeMux, srv imageService) *Handler { func New(srv imageService) *Handler {
mux := http.NewServeMux()
h := &Handler{ h := &Handler{
Handler: mux,
img: srv, img: srv,
} }
@@ -22,5 +25,5 @@ func New(mux *http.ServeMux, srv imageService) *Handler {
} }
type imageService interface { type imageService interface {
GetImage(ctx context.Context, uid string, options images.Options) (img io.Reader, mimeType string, err error) GetScaledImage(ctx context.Context, uid string, options images.Options) (img io.Reader, mimeType string, err error)
} }
+13 -2
View File
@@ -1,14 +1,25 @@
package config package config
import "time"
func Default() Cfg { func Default() Cfg {
return Cfg{ return Cfg{
Image: &ImageConfig{ Image: &Image{
Concurency: 10, Concurency: 10,
Quality: 80, Quality: 80,
CacheLifetime: time.Hour * 24 * 31,
}, },
Tracking: &UmamiConfig{ Tracking: &Umami{
ScriptSrc: "https://umami.schreifuchs.ch/script.js", ScriptSrc: "https://umami.schreifuchs.ch/script.js",
WebsiteID: "54d8a379-77d5-4c20-b46d-5c9a6c9e39bd", WebsiteID: "54d8a379-77d5-4c20-b46d-5c9a6c9e39bd",
}, },
Gallery: &Gallery{
Path: "photos",
},
Cache: &Valkey{
RefreshTime: time.Minute * 10,
LifeTime: time.Hour * 48,
},
LogJSON: true,
} }
} }
+23 -6
View File
@@ -2,15 +2,21 @@ package config
import ( import (
"context" "context"
"log/slog"
"time"
"github.com/sethvargo/go-envconfig" "github.com/sethvargo/go-envconfig"
) )
type Cfg struct { type Cfg struct {
FileSystem *WebdavConfig `env:", prefix=FILESYSTEM_"` FileSystem *WebdavConfig `env:", prefix=FILESYSTEM_"`
Cache *ValkeyConfig `env:", prefix=CACHE_"` Cache *Valkey `env:", prefix=CACHE_"`
Image *ImageConfig `env:", prefix=IMAGE_"` Image *Image `env:", prefix=IMAGE_"`
Tracking *UmamiConfig `env:", prefix=TRACKING_"` Tracking *Umami `env:", prefix=TRACKING_"`
Gallery *Gallery `env:", prefix=GALLERY_"`
LogLevel slog.Level `env:"LOG_LEVEL, default=DEBUG"`
LogJSON bool `env:"LOG_JSON"`
} }
type WebdavConfig struct { type WebdavConfig struct {
@@ -19,11 +25,14 @@ type WebdavConfig struct {
Password string `env:"PASSWORD"` Password string `env:"PASSWORD"`
} }
type ValkeyConfig struct { type Valkey struct {
ClientName string `env:"CLIENTNAME"` ClientName string `env:"CLIENTNAME"`
Username string `env:"USERNAME"` Username string `env:"USERNAME"`
Password string `env:"PASSWORD"` Password string `env:"PASSWORD"`
RefreshTime time.Duration `env:"REFRESH_TIME"`
LifeTime time.Duration `env:"LIFE_TIME"`
// InitAddress point to valkey nodes. // InitAddress point to valkey nodes.
// Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success. // Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success.
// If len(InitAddress) == 1 and the address is not running in cluster mode, valkey will fall back to the single client mode. // If len(InitAddress) == 1 and the address is not running in cluster mode, valkey will fall back to the single client mode.
@@ -32,12 +41,17 @@ type ValkeyConfig struct {
InitAddress []string `env:"ADDRESS"` InitAddress []string `env:"ADDRESS"`
} }
type ImageConfig struct { type Image struct {
Concurency int64 `env:"CONCURENCY"` Concurency int64 `env:"CONCURENCY"`
Quality int `env:"QUALITY"` Quality int `env:"QUALITY"`
CacheLifetime time.Duration `env:"CACHE_LIFETIME"`
} }
type UmamiConfig struct { type Gallery struct {
Path string `env:"PATH"`
}
type Umami struct {
ScriptSrc string `env:"SCRIPT_SRC"` ScriptSrc string `env:"SCRIPT_SRC"`
WebsiteID string `env:"WEBSITE_ID"` WebsiteID string `env:"WEBSITE_ID"`
} }
@@ -51,6 +65,7 @@ func Read(ctx context.Context) (cfg Cfg, err error) {
// overridden. // overridden.
DefaultDelimiter: ";", DefaultDelimiter: ";",
DefaultSeparator: "@", DefaultSeparator: "@",
DefaultOverwrite: true,
Lookuper: envconfig.MultiLookuper(secretLookuper(), envconfig.OsLookuper()), Lookuper: envconfig.MultiLookuper(secretLookuper(), envconfig.OsLookuper()),
}) })
@@ -58,5 +73,7 @@ func Read(ctx context.Context) (cfg Cfg, err error) {
return return
} }
slog.Info("rft", "rft", cfg.Cache.RefreshTime)
return return
} }
+3 -2
View File
@@ -23,11 +23,11 @@ type CachedClient struct {
} }
// NewCachedClient creates a new CachedClient. // NewCachedClient creates a new CachedClient.
func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *CachedClient { func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration, lifetime time.Duration) *CachedClient {
c := &CachedClient{ c := &CachedClient{
impl: impl, impl: impl,
cache: client, cache: client,
ttl: refreshtime * 5, ttl: lifetime,
} }
go func() { go func() {
@@ -42,6 +42,7 @@ func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *
} }
func (c *CachedClient) revalidate(ctx context.Context) { func (c *CachedClient) revalidate(ctx context.Context) {
slog.Info("starting revalidation")
go c.revalidateReadDir(ctx) go c.revalidateReadDir(ctx)
go c.revalidateRead(ctx) go c.revalidateRead(ctx)
go c.revalidateStat(ctx) go c.revalidateStat(ctx)
@@ -150,7 +150,7 @@ func TestCachedClient_Stat(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/test.txt", []byte("hello")) mockFS.AddFile("/test.txt", []byte("hello"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Cache miss // First call: Cache miss
info, err := c.Stat("/test.txt") info, err := c.Stat("/test.txt")
@@ -209,7 +209,7 @@ func TestCachedClient_Read(t *testing.T) {
content := []byte("hello world") content := []byte("hello world")
mockFS.AddFile("/read.txt", content) mockFS.AddFile("/read.txt", content)
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Miss // First call: Miss
data, err := c.Read("/read.txt") data, err := c.Read("/read.txt")
@@ -257,7 +257,7 @@ func TestCachedClient_ReadDir(t *testing.T) {
mockFS.AddFile("/dir/f1.txt", []byte("1")) mockFS.AddFile("/dir/f1.txt", []byte("1"))
mockFS.AddFile("/dir/f2.txt", []byte("2")) mockFS.AddFile("/dir/f2.txt", []byte("2"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Miss // First call: Miss
infos, err := c.ReadDir("/dir") infos, err := c.ReadDir("/dir")
@@ -303,7 +303,7 @@ func TestCachedClient_RevalidateStat(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/reval.txt", []byte("initial")) mockFS.AddFile("/reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, _ = c.Stat("/reval.txt") _, _ = c.Stat("/reval.txt")
@@ -381,7 +381,7 @@ func TestCachedClient_RevalidateRead(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/read_reval.txt", []byte("initial")) mockFS.AddFile("/read_reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, _ = c.Read("/read_reval.txt") _, _ = c.Read("/read_reval.txt")
@@ -431,7 +431,7 @@ func TestCachedClient_RevalidateReadDir(t *testing.T) {
mockFS.AddDir("/dir_reval") mockFS.AddDir("/dir_reval")
mockFS.AddFile("/dir_reval/f1.txt", []byte("1")) mockFS.AddFile("/dir_reval/f1.txt", []byte("1"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, err = c.ReadDir("/dir_reval") _, err = c.ReadDir("/dir_reval")
@@ -461,4 +461,3 @@ func TestCachedClient_RevalidateReadDir(t *testing.T) {
t.Errorf("expected 1 hit, got %d", c.hits.Load()) t.Errorf("expected 1 hit, got %d", c.hits.Load())
} }
} }
-1
View File
@@ -26,7 +26,6 @@ func (c *CachedClient) Read(path string) ([]byte, error) {
if err == nil && len(cached) > 0 { if err == nil && len(cached) > 0 {
c.readHits.Add(1) c.readHits.Add(1)
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
var file readFile var file readFile
err := json.Unmarshal(cached, &file) err := json.Unmarshal(cached, &file)
-1
View File
@@ -22,7 +22,6 @@ func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) {
if err := json.Unmarshal([]byte(val), &cached); err == nil { if err := json.Unmarshal([]byte(val), &cached); err == nil {
c.readDirHits.Add(1) c.readDirHits.Add(1)
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
infos := make([]os.FileInfo, len(cached)) infos := make([]os.FileInfo, len(cached))
for i, f := range cached { for i, f := range cached {
infos[i] = f infos[i] = f
-1
View File
@@ -19,7 +19,6 @@ func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) {
var f File var f File
if err := json.Unmarshal([]byte(val), &f); err == nil { if err := json.Unmarshal([]byte(val), &f); err == nil {
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
return f, nil return f, nil
} }
@@ -1,4 +1,4 @@
package server package middleware
import ( import (
"fmt" "fmt"
@@ -17,7 +17,7 @@ func (c *captureWriter) WriteHeader(statusCode int) {
c.ResponseWriter.WriteHeader(statusCode) c.ResponseWriter.WriteHeader(statusCode)
} }
func (s *Server) loggingMiddleware(next http.Handler) http.Handler { func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
cw := &captureWriter{ cw := &captureWriter{
@@ -35,7 +35,7 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
} }
// cacheMiddleware adds Cache-Control headers to the response. // cacheMiddleware adds Cache-Control headers to the response.
func cacheMiddleware(maxAge time.Duration) func(next http.Handler) http.Handler { func Cache(maxAge time.Duration) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds())) w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()))
+48
View File
@@ -0,0 +1,48 @@
package templer
import (
"context"
)
// GetHeader returns a slice of strings that should be added to the html head.
// Because strings are immutable a slice of strings is used.
func GetHeader(ctx context.Context) []string {
v := ctx.Value(headerKey)
if v == nil {
return []string{}
}
header, ok := v.([]string)
if !ok {
return []string{}
}
return header
}
// AppendHeader lets you add lines to the header.
func AppendHeader(ctx context.Context, v ...string) context.Context {
header := GetHeader(ctx)
header = append(header, v...)
return context.WithValue(ctx, headerKey, header)
}
// AppendHeader lets you add lines to the header.
func PageTitle(ctx context.Context, title string) context.Context {
return context.WithValue(ctx, titleKey, title)
}
// AppendHeader lets you add lines to the header.
func GetTitle(ctx context.Context, main string) string {
v := ctx.Value(titleKey)
if v == nil {
return main
}
title, ok := v.(string)
if !ok {
return main
}
return title + " | " + main
}
+32 -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"
) )
@@ -11,6 +13,8 @@ type ctxKey int
const ( const (
pathKey ctxKey = iota pathKey ctxKey = iota
headerKey
titleKey
) )
func Middleware(next http.Handler) http.HandlerFunc { func Middleware(next http.Handler) http.HandlerFunc {
@@ -23,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)
}
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package server
import (
"fmt"
"net/http"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restgallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/resthome"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restimage"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/middleware"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/layouts"
"github.com/studio-b12/gowebdav"
"github.com/valkey-io/valkey-go"
)
func (s *Server) allRoutes() (h http.Handler, err error) {
mux := http.NewServeMux()
registerStatic(mux)
other, err := s.dynamicRoutes()
if err != nil {
return
}
mux.Handle("/", other)
h = mux
h = templer.Middleware(h)
h = middleware.Logging(h)
return h, nil
}
func (s *Server) dynamicRoutes() (h http.Handler, err error) {
mux := http.NewServeMux()
webdavClient := gowebdav.NewClient(s.cfg.FileSystem.URL, s.cfg.FileSystem.User, s.cfg.FileSystem.Password)
err = webdavClient.Connect()
if err != nil {
err = fmt.Errorf("could not connect webdavClient: %w", err)
return
}
var fs filesystem.FS = webdavClient
valkeyClient, err := valkey.NewClient(valkey.ClientOption{
InitAddress: s.cfg.Cache.InitAddress,
Password: s.cfg.Cache.Password,
})
if err != nil {
err = fmt.Errorf("failed to create valkey client: %w", err)
return
}
if err != nil {
} else {
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, s.cfg.Cache.RefreshTime, s.cfg.Cache.LifeTime)
}
imageSrv := images.New(fs, s.cfg.Image, valkeyClient)
mux.Handle("/images/", rest.Use(
restimage.New(imageSrv),
middleware.Cache(time.Hour*24*5),
))
translator := translate.New()
renderer := layouts.New(s.cfg)
mux.Handle("/", rest.Use(
func() http.Handler {
mux := http.NewServeMux()
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)))
return mux
}(),
translator.Middleware,
middleware.Cache(time.Minute),
))
return mux, nil
}
-65
View File
@@ -1,65 +0,0 @@
package server
import (
"log/slog"
"net/http"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/resthome"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restimage"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/layouts"
"github.com/studio-b12/gowebdav"
"github.com/valkey-io/valkey-go"
)
func (s *Server) RegisterRoutes() (h http.Handler) {
mux := http.NewServeMux()
registerStatic(mux)
mux.Handle("/", s.registerOther())
h = mux
h = templer.Middleware(h)
h = s.loggingMiddleware(h)
return h
}
func (s *Server) registerOther() (h http.Handler) {
mux := http.NewServeMux()
webdavClient := gowebdav.NewClient(s.cfg.FileSystem.URL, s.cfg.FileSystem.User, s.cfg.FileSystem.Password)
if err := webdavClient.Connect(); err != nil {
slog.Error("could not connect webdavClient", "err", err)
}
var fs filesystem.FS = webdavClient
valkeyClient, err := valkey.NewClient(valkey.ClientOption{
InitAddress: s.cfg.Cache.InitAddress,
Password: s.cfg.Cache.Password,
})
if err != nil {
slog.Error("failed to create valkey client", "err", err)
} else {
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, 5*time.Hour)
}
renderer := layouts.New(s.cfg)
_ = resthome.New(mux, renderer, page.New(fs))
_ = restimage.New(mux, images.New(fs, s.cfg.Image, valkeyClient))
mux.Handle("/", http.FileServer(http.FS(web.GetStaticFS())))
translator := translate.New()
h = mux
h = translator.Middleware(h)
h = cacheMiddleware(time.Minute * 5)(h)
return h
}
+11 -3
View File
@@ -1,3 +1,4 @@
// Package server provides the http server for schreifuchs.ch.
package server package server
import ( import (
@@ -17,6 +18,7 @@ type Server struct {
cfg config.Cfg cfg config.Cfg
} }
// Start starts a new http server. When the context gets canceled, the server stops gracefully.
func Start(ctx context.Context, cfg config.Cfg) (err error) { func Start(ctx context.Context, cfg config.Cfg) (err error) {
port, _ := strconv.Atoi(os.Getenv("PORT")) port, _ := strconv.Atoi(os.Getenv("PORT"))
if port == 0 { if port == 0 {
@@ -27,10 +29,16 @@ func Start(ctx context.Context, cfg config.Cfg) (err error) {
cfg: cfg, cfg: cfg,
} }
hander, err := s.allRoutes()
if err != nil {
err = fmt.Errorf("could not setup routes: %w", err)
return
}
// Declare Server config // Declare Server config
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%d", s.port), Addr: fmt.Sprintf(":%d", s.port),
Handler: s.RegisterRoutes(), Handler: hander,
IdleTimeout: time.Minute, IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
@@ -45,7 +53,7 @@ func Start(ctx context.Context, cfg config.Cfg) (err error) {
// Shutdown server when ctx done // Shutdown server when ctx done
if err = srv.Shutdown(context.Background()); err != nil { if err = srv.Shutdown(context.Background()); err != nil {
// Error from closing listeners, or context timeout: // Error from closing listeners, or context timeout:
err = fmt.Errorf("error whilse shutting down server: %w", err) err = fmt.Errorf("error while shutting down server: %w", err)
} }
close(idleConnsClosed) close(idleConnsClosed)
@@ -53,7 +61,7 @@ func Start(ctx context.Context, cfg config.Cfg) (err error) {
slog.Info("starting server", "address", srv.Addr) slog.Info("starting server", "address", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != http.ErrServerClosed {
return fmt.Errorf("error while server ListenAndServe: %w", err) return fmt.Errorf("error while serving: %w", err)
} }
<-idleConnsClosed <-idleConnsClosed
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"time" "time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/middleware"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
) )
@@ -14,7 +15,7 @@ func registerStatic(mux *http.ServeMux) {
fileSystem := web.GetStaticFS() fileSystem := web.GetStaticFS()
// we still use go's fileServer to avoid unnecessary implementation // we still use go's fileServer to avoid unnecessary implementation
fileServer := cacheMiddleware(time.Hour * 24)(http.FileServer(http.FS(fileSystem))) fileServer := middleware.Cache(time.Hour * 24)(http.FileServer(http.FS(fileSystem)))
err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -26,7 +27,7 @@ func registerStatic(mux *http.ServeMux) {
path = "/" + path path = "/" + path
slog.Debug("registering file", "path", path) slog.Debug("registered static file", "path", path)
mux.Handle(path, fileServer) mux.Handle(path, fileServer)
+5 -5
View File
@@ -4,9 +4,9 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --minify", "build:css": "tailwindcss -i ./web/src/css/input.css -o ./web/static/css/output.css --minify",
"watch:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --watch", "watch:css": "tailwindcss -i ./web/src/css/input.css -o ./web/static/css/output.css --watch",
"build:js": "cp node_modules/htmx.org/dist/htmx.min.js web/static/js/htmx.min.js && cp ./node_modules/htmx-ext-preload/dist/preload.min.js ./web/static/js/preload.min.js" "build:js": "node scripts/build.mjs"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -16,11 +16,11 @@
"@tailwindcss/cli": "^4.1.18", "@tailwindcss/cli": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"esbuild": "^0.27.4",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"dependencies": { "dependencies": {
"htmx-ext-preload": "^2.1.2", "htmx.org": "^4.0.0"
"htmx.org": "^2.0.8"
} }
} }
+277 -15
View File
@@ -8,12 +8,9 @@ importers:
.: .:
dependencies: dependencies:
htmx-ext-preload:
specifier: ^2.1.2
version: 2.1.2
htmx.org: htmx.org:
specifier: ^2.0.8 specifier: ^4.0.0
version: 2.0.8 version: 4.0.0
devDependencies: devDependencies:
'@tailwindcss/cli': '@tailwindcss/cli':
specifier: ^4.1.18 specifier: ^4.1.18
@@ -24,6 +21,9 @@ importers:
autoprefixer: autoprefixer:
specifier: ^10.4.24 specifier: ^10.4.24
version: 10.4.24(postcss@8.5.6) version: 10.4.24(postcss@8.5.6)
esbuild:
specifier: ^0.27.4
version: 0.27.4
postcss: postcss:
specifier: ^8.5.6 specifier: ^8.5.6
version: 8.5.6 version: 8.5.6
@@ -33,6 +33,162 @@ importers:
packages: packages:
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -270,6 +426,11 @@ packages:
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -280,11 +441,9 @@ 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-ext-preload@2.1.2: htmx.org@4.0.0:
resolution: {integrity: sha512-foD06XAITUUQApCUQnPexyJ6B7+4vFGMQ+t42unTVTGRu/CYHdAAt6oHg7eekPHT7NRnbM5hjnGVKtClryPabg==} resolution: {integrity: sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==}
hasBin: true
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==}
@@ -430,6 +589,84 @@ packages:
snapshots: snapshots:
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -617,17 +854,42 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.0 tapable: 2.3.0
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
escalade@3.2.0: {} escalade@3.2.0: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
htmx-ext-preload@2.1.2: htmx.org@4.0.0: {}
dependencies:
htmx.org: 2.0.8
htmx.org@2.0.8: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

-1976
View File
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 112 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

+69
View File
@@ -0,0 +1,69 @@
import * as esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
const filesToCopy = [
"node_modules/htmx.org/dist/htmx.min.js",
"node_modules/htmx.org/dist/ext/hx-preload.min.js",
"node_modules/htmx.org/dist/ext/hx-head.min.js",
];
const filesToMinifyRoot = "web/src/js";
const outputRoot = "web/static/js";
async function minifyFiles() {
try {
const files = (await fs.readdir(filesToMinifyRoot))
.filter((p) => p.endsWith(".js"))
.map((p) => {
return {
src: path.join(filesToMinifyRoot, p),
dest: path.join(outputRoot, p.substring(0, p.length - 3) + ".min.js"),
};
});
for (const file of files) {
await esbuild.build({
entryPoints: [file.src],
bundle: false,
minify: true,
outfile: file.dest,
});
console.log(`Minified ${file.src} to ${file.dest}`);
}
} catch (err) {
console.error("could not minify files", err);
}
}
async function coppyFromNodemodules() {
try {
const fileMap = filesToCopy.map((file) => {
return {
src: file,
dest: path.join(outputRoot, path.basename(file)),
};
});
for (const file of fileMap) {
await fs.copyFile(file.src, file.dest);
console.log(`Copied ${path.basename(file.dest)}`);
}
} catch (err) {
console.error("JS build failed:", err);
process.exit(2);
}
}
async function build() {
console.log("Starting JS build...");
await minifyFiles();
await coppyFromNodemodules();
console.log("JS build complete!");
}
build();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

+62
View File
@@ -0,0 +1,62 @@
package testdata
import (
"bytes"
"embed"
"io"
"os"
"strings"
)
//go:embed *
var fs embed.FS
func FS() mockFS {
return mockFS{
FS: fs,
}
}
type mockFS struct {
embed.FS
}
type mockMimer struct{ os.FileInfo }
func (m *mockMimer) ContentType() string {
parts := strings.Split(m.Name(), ".")
suffix := parts[len(parts)-1]
switch suffix {
case "jpg":
return "image/jpeg"
default:
return ""
}
}
func (m mockFS) Read(path string) (b []byte, err error) {
file, err := m.Open(path)
if err != nil {
return
}
defer file.Close()
buff := bytes.NewBuffer(make([]byte, 0, 1024))
_, err = io.Copy(buff, file)
return buff.Bytes(), err
}
func (m mockFS) Stat(path string) (info os.FileInfo, err error) {
file, err := m.Open(path)
if err != nil {
return
}
info, err = file.Stat()
info = &mockMimer{info}
return
}
-5
View File
@@ -1,5 +0,0 @@
package components
templ Hello() {
<h1>Hello World</h1>
}
+34
View File
@@ -0,0 +1,34 @@
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>
}
+51 -18
View File
@@ -1,34 +1,69 @@
package layouts package layouts
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate" import (
import "strings" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"strings"
)
templ head(cfg config.Cfg, title string) {
<head>
<meta charset="UTF-8"/>
<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>
<link rel="icon" type="image/png" href="/favicon.png"/>
<link fetchpriority="high" href="/css/output.css" rel="stylesheet"/>
<script src="/js/htmx.min.js"></script>
<script src="/js/hx-head.min.js"></script>
<script src="/js/hx-preload.min.js"></script>
<script src="/js/preload-links.min.js"></script>
<script src={ cfg.Tracking.ScriptSrc } data-website-id={ cfg.Tracking.WebsiteID }></script>
for _, h := range templer.GetHeader(ctx) {
@templ.Raw(h)
}
</head>
}
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> @head(cfg, title)
<meta charset="UTF-8"/> <body class="bg-black dark" hx-boost:inherited="true" hx-target:inherited="body">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> @body() {
<title>{ title }</title> { children... }
<link rel="icon" type="image/png" href="/favicon.png"/> }
<link fetchpriority="high" href="/css/output.css" rel="stylesheet"/> </body>
<script fetchpriority="low" src="/js/htmx.min.js"></script> </html>
<script src="/js/preload.min.js"></script> }
<script src={ cfg.Tracking.ScriptSrc } data-website-id={ cfg.Tracking.WebsiteID }></script>
</head> templ Page(cfg config.Cfg, title string) {
<body class="bg-black dark" hx-boost="true" hx-target="main" hx-ext="preload"> @head(cfg, title)
@body() {
{ children... }
}
}
templ Component() {
for _, h := range templer.GetHeader(ctx) {
@templ.Raw(h)
}
{ children... }
}
templ body() {
<header <header
class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start" class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
> >
<a class="text-white text-2xl" href={ translate.Path(ctx, "") }>schreifuchs.ch</a> <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"> <nav class="flex gap-2 p-2">
for _, lang := range translate.SupportedLanguages(ctx) { for _, lang := range translate.SupportedLanguages(ctx) {
<a <a
href={ translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...) } href={ translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...) }
hreflang={ lang } hreflang={ lang }
data-no-translate data-no-translate
hx-preload="mouseover"
class="text-white hover:underline uppercase" class="text-white hover:underline uppercase"
> >
{ lang } { lang }
@@ -39,6 +74,4 @@ templ Base(cfg config.Cfg, title string) {
<main class="text-white"> <main class="text-white">
{ children... } { children... }
</main> </main>
</body>
</html>
} }
+18 -6
View File
@@ -2,12 +2,12 @@ package layouts
import ( import (
"context" "context"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
@@ -21,19 +21,31 @@ func New(cfg config.Cfg) *Layouts {
} }
} }
// Render renders a page. If the query paramenter "c" is set // RenderPage renders a page. If the query paramenter HX-Request is set
// the component gets rendered without the layout // the component gets rendered without the layout
func (l *Layouts) Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) { func (l *Layouts) RenderPage(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) {
withLayout := !r.URL.Query().Has("c") title := templer.GetTitle(ctx, "Schreifuchs")
fmt.Println("layout: ", withLayout)
slog.Debug("HX-Request", "value", r.Header.Get("HX-Request")) slog.Debug("HX-Request", "value", r.Header.Get("HX-Request"))
if r.Header.Get("HX-Request") != "true" { if r.Header.Get("HX-Request") != "true" {
ctx = templ.WithChildren(ctx, component) ctx = templ.WithChildren(ctx, component)
component = Base(l.cfg, "Schreifuchs") component = Base(l.cfg, title)
} else {
ctx = templ.WithChildren(ctx, component)
component = Page(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)
}
}
+127
View File
@@ -0,0 +1,127 @@
package pages
import (
"fmt"
"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) {
<div class="p-5">
@components.Modal(detailsID) {
// Placeholder: only visible until the first image is swapped in.
// prev/next are nil because relative links would be resolved
// against the gallery index, not the image page.
if len(g.SM.Images) > 0 {
@GalleryImage(g.SM.Images[0].UID, nil, nil)
}
}
<div
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>
}
// Define a templ script to hold complex post-load behavior
templ GalleryImage(image string, next *gallery.Image, prev *gallery.Image) {
<link rel="preload" href={ "/images/" + image + "?w=800" } as="image"/>
<div class="relative w-10/12 h-10/12 flex items-center justify-center overflow-hidden ">
<!-- 1. Preloaded Base Image (w=800) -->
<img
src={ "/images/" + image + "?w=800" }
alt=""
class="absolute inset-0 w-full h-full object-contain"
/>
<!-- 2. Responsive Image Layer (Overlaid on top) -->
<img
src={ "/images/" + image + "?w=800" }
srcset={ "/images/" + image + "?w=400 400w, /images/" + image + "?w=800 800w, /images/" + image + "?w=1200 1200w, /images/" + image + "?w=1600 1600w" }
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="Image description"
onload="this.decode().then(() => {this.classList.remove('opacity-0')}).catch(() => {})"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-900 opacity-0"
/>
<!-- 3. Navigation Controls -->
if prev != nil {
<button
hx-get={ templer.RelativePath(ctx, "../"+prev.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
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>
}
templ Gallery(g gallery.Gallery) {
<div
class="grid gap-5"
style={ fmt.Sprintf("grid-template-columns: repeat(%d, 1fr); grid-template-rows: repeat(%d, auto);", g.GridWith, g.GridHeight) }
>
for _, img := range g.Images {
<div
style={ fmt.Sprintf("grid-column: %d / span %d; grid-row: %d / span %d;aspect-ratio: %d / %d;", img.X+1, img.Width, img.Y+1, img.Height, img.Width, img.Height) }
class="overflow-hidden shadow-sm"
>
<button
hx-get={ templer.RelativePath(ctx, "./"+img.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
>
<img
src={ "/images/" + img.UID + "?w=800" }
loading="lazy"
class="w-full h-full object-cover"
alt="Gallery item"
/>
</button>
</div>
}
</div>
}
+5 -1
View File
@@ -9,7 +9,11 @@ import (
templ Home(pages []page.PageHeader) { templ Home(pages []page.PageHeader) {
for _, page := range pages { for _, page := range pages {
@components.ImageTile("/images/" + page.CoverImageUID) { @components.ImageTile("/images/" + page.CoverImageUID) {
<a class="flex items-center justify-center h-full " href={ translate.Path(ctx, "/"+page.UID) } preload="mouseover"> <a
class="flex items-center justify-center h-full "
href={ translate.Path(ctx, "/"+page.UID) }
hx-preload="mouseover"
>
<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"
> >
File renamed without changes.
+76
View File
@@ -0,0 +1,76 @@
/**
* @file preload-links.js
* @description HTMX helper to extract <link rel=preload> and image hints from fetched HTML responses.
* @generated By opencode (AI Agent)
* @model gemini-3-flash-preview
* @date Tue Sep 29 2026
*/
(function () {
const originalFetch = window.fetch;
window.fetch = function (input, init) {
const responsePromise = originalFetch.apply(this, arguments);
let method = init?.method;
if (!method && input instanceof Request) method = input.method;
method = (method || "GET").toUpperCase();
responsePromise
.then((response) => {
const contentType = response.headers.get("content-type") || "";
if (method !== "GET" || !contentType.includes("text/html")) return;
return response
.clone()
.text()
.then((html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll('link[rel="preload"]').forEach((link) => {
const href = link.getAttribute("href");
if (!href) return;
if (
document.head.querySelector(
`link[rel="preload"][href="${href}"]`,
)
)
return;
const newLink = document.createElement("link");
newLink.rel = "preload";
newLink.href = href;
["as", "type", "media", "fetchpriority", "crossorigin"].forEach(
(attr) => {
const val = link.getAttribute(attr);
if (val) newLink.setAttribute(attr, val);
},
);
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-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--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;displayLine 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
+1 -1
View File
@@ -1 +1 @@
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 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
+1
View File
@@ -0,0 +1 @@
!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
@@ -0,0 +1 @@
(()=>{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
View File
@@ -0,0 +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}})();
-1
View File
@@ -1 +0,0 @@
(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}})();