├── .gitignore ├── .vscode └── launch.json ├── Dockerfile-dev ├── Makefile ├── README.md ├── Taskfile.yml ├── cmd └── site │ └── main.go ├── go.mod ├── go.sum ├── sql ├── .gitignore ├── db.go ├── migrations │ └── 2411181100_initial.sql ├── queries │ └── queries.sql └── sqlc.yml └── web ├── gen └── css │ ├── .gitignore │ └── site.css ├── postcss.config.js ├── routes_home.go ├── routes_results.go ├── routes_results.templ ├── routes_vote.go ├── routes_vote.templ ├── server.go ├── shared.templ ├── static ├── .gitignore ├── datastar.js └── datastar.js.map └── tailwind.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .task 2 | *.log 3 | *_templ.go 4 | *.sqlite* 5 | website -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Website", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/site/main.go", 13 | "cwd": "${workspaceFolder}" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile-dev: -------------------------------------------------------------------------------- 1 | ARG TAG=1.23 2 | 3 | FROM golang:$TAG 4 | 5 | WORKDIR /app 6 | 7 | # Install packages 8 | RUN apt update && sudo apt upgrade \ 9 | && \ 10 | set -eux; \ 11 | # Packages to install 12 | apt install -y \ 13 | git \ 14 | rsync \ 15 | && \ 16 | # Clean out directories that don't need to be part of the image 17 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ 18 | && \ 19 | # Install needed Go tooling \ 20 | go install github.com/go-task/task/v3/cmd/task@latest \ 21 | && \ 22 | go install github.com/a-h/templ/cmd/templ@latest \ 23 | && \ 24 | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest \ 25 | && \ 26 | go install github.com/delaneyj/toolbelt/sqlc-gen-zombiezen@latest \ 27 | && \ 28 | go install golang.org/x/tools/cmd/goimports@latest \ 29 | && \ 30 | # Make this a safe .git directory 31 | git config --global --add safe.directory /app 32 | 33 | ENTRYPOINT ["/bin/sh"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAG?=1.23 2 | CONTAINER?=$(shell basename $(CURDIR))-dev 3 | DEV_PORT?=4321 4 | IMAGE_INFO=$(shell docker image inspect $(CONTAINER):$(TAG)) 5 | IMAGE_NAME=${CONTAINER}:${TAG} 6 | DOCKER_RUN=docker container run --rm -it -v "${CURDIR}":/app -v go-modules:/go/pkg/mod 7 | ARCH=$(shell uname -m) 8 | 9 | .PHONY: build clean dev image-build task test ssh 10 | 11 | # Perform a dist build 12 | build: image-check 13 | ${DOCKER_RUN} --name ${CONTAINER}-$@ ${IMAGE_NAME} build 14 | # Clean up all build artifacts to start from scratch 15 | clean: 16 | docker image rm ${IMAGE_NAME} 17 | docker volume rm go-modules 18 | # Run the development server 19 | dev: --image-check 20 | ${DOCKER_RUN} --name ${CONTAINER}-$@ -e DEV_PORT="${DEV_PORT}" -p ${DEV_PORT}:${DEV_PORT} ${IMAGE_NAME} -c 'task -w' 21 | # Build the Docker image 22 | image-build: 23 | docker build -f Dockerfile-dev . -t ${IMAGE_NAME} --build-arg TAG=${TAG} --no-cache 24 | ${DOCKER_RUN} --name ${CONTAINER}-$@ ${IMAGE_NAME} -c 'task tools' 25 | # Run the passed in task command 26 | task: --image-check 27 | ${DOCKER_RUN} --name ${CONTAINER}-$@ -e DEV_PORT="${DEV_PORT}" -p ${DEV_PORT}:${DEV_PORT} ${IMAGE_NAME} -c 'task $(filter-out $@,$(MAKECMDGOALS)) $(MAKEFLAGS)' 28 | # Open a shell inside of the container 29 | ssh: --image-check 30 | ${DOCKER_RUN} --name ${CONTAINER}-$@ --entrypoint=/bin/sh ${IMAGE_NAME} 31 | # Ensure the image has been created 32 | --image-check: 33 | ifeq ($(IMAGE_INFO), []) 34 | --image-check: image-build 35 | endif 36 | %: 37 | @: 38 | # ref: https://stackoverflow.com/questions/6273608/how-to-pass-argument-to-makefile-from-command-line 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 1 App 5 Stacks ported to Go+Templ+Datastar 2 | 3 | The [original code](https://github.com/t3dotgg/1app5stacks ) that goes with [this video](https://www.youtube.com/watch?v=O-EWIlZW0mM) I think did Go dirty. 4 | 5 | # Run 6 | 7 | ## With Docker 8 | 9 | If you have Docker installed, it takes care of the setup for you in a Dockerized environment, which allows you to get it up and running quickly & easily. 10 | 11 | The only requirement is that you have [Docker](https://www.docker.com/products/docker-desktop) installed (you do not need `golang`, `tmpl`, `sqlc` or any other project dependencies installed locally). 12 | 13 | In terminal, `cd` to the project directory, and then type: 14 | 15 | ``` 16 | make dev 17 | ``` 18 | 19 | The first time you run this command, it may take a bit of time to build the Docker image, and download all of the appropriate packages, and cache them locally. 20 | 21 | Then just navigate to `http://localhost:4321` in your browser, and the project will be up and running. 22 | 23 | ## Without Docker 24 | 25 | If you have [Task](https://taskfile.dev/#/) and [Go 1.23.3](https://golang.org/) installed you can run the following: 26 | 27 | ```bash 28 | task tools 29 | task -w 30 | ``` 31 | 32 | # What's different? 33 | 34 | 1. 321LOC of Go across 6 files (including HTTPServer) and ~125LOC of [Templ](https://templ.guide/) across 3 files for UI. Could be one of each but tried to match the spirit of the original. 35 | 1. Templ could be shorter but expanded lines for readability. 36 | 2. Pretty sure it's the smallest codebase of any of the apps in the original. 37 | 2. Metrics 38 | 1. I'm pretty sure it's the fastest (queries take < 1ms on my machine) 39 | 2. Smallest memory footprint (19MB sustained) of any of the apps in the original. 40 | 3. It handles speculation rules asynchronously for precaching the pokemon images. 41 | 4. Use pure Go SQLite so N+1 queries are not a problem. 42 | 5. It's a single binary with no dependencies. 43 | 6. Datastar 44 | 1. One time cost of 12KiB of JS. 45 | 2. Handles all UI interactions and backend communication. 46 | 3. The version is [ALL the plugins](https://data-star.dev/bundler) we are only using a handful of them for this demo 47 | 4. No websockets, just normal HTTP requests that can work on HTTP 2/3. 48 | 49 | I hope this clear up why it's not 50 | > [from a glance like Live View](https://x.com/theo/status/1858032204355612770) 51 | > 52 | > ~ [Theo](https://x.com/theo) -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | 3 | version: "3" 4 | 5 | interval: 100ms 6 | 7 | tasks: 8 | tools: 9 | cmds: 10 | - go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest 11 | - go install github.com/delaneyj/toolbelt/sqlc-gen-zombiezen@latest 12 | - go install github.com/a-h/templ/cmd/templ@latest 13 | - go get github.com/a-h/templ 14 | - go install golang.org/x/tools/cmd/goimports@latest 15 | - platforms: [linux/amd64] 16 | cmd: test -f web/gen/css/tailwindcli || wget -O web/gen/css/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-linux-x64 17 | 18 | - platforms: [linux/arm64] 19 | cmd: test -f web/gen/css/tailwindcli || wget -O web/gen/css/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-linux-arm64 20 | 21 | - platforms: [darwin/arm64] 22 | cmd: test -f web/gen/css/tailwindcli || wget -O web/gen/css/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-macos-arm64 23 | 24 | - platforms: [darwin/amd64] 25 | cmd: test -f web/gen/css/tailwindcli || wget -O web/gen/css/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-macos-x64 26 | 27 | - platforms: [windows] 28 | cmd: test -f web/gen/css/tailwindcli || wget -O web/gen/css/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-windows-x64.exe 29 | 30 | - platforms: [openbsd, dragonfly, freebsd, netbsd] 31 | cmd: pnpm add tailwindcss @tailwindcss/container-queries @tailwindcss/typography daisyui 32 | 33 | - platforms: [openbsd, dragonfly, freebsd, netbsd] 34 | cmd: test -f web/gen/css/tailwindcli || (echo "#!/bin/sh" > web/gen/css/tailwindcli && echo "tailwindcss $@" >> web/gen/css/tailwindcli) 35 | - chmod +x web/gen/css/tailwindcli 36 | 37 | css: 38 | dir: web 39 | sources: 40 | - "**/*.templ" 41 | - "**/*.md" 42 | - "**/*.go" 43 | generates: 44 | - "static/css/site.css" 45 | cmds: 46 | - ./gen/css/tailwindcli build -i gen/css/site.css -o static/site.css 47 | 48 | sqlc: 49 | dir: sql 50 | sources: 51 | - "**/*.sql" 52 | cmds: 53 | - sqlc generate 54 | - goimports -w . 55 | 56 | templ: 57 | env: 58 | TEMPL_EXPERIMENT: rawgo 59 | generates: 60 | - "**/*_templ.go" 61 | sources: 62 | - "**/*.templ" 63 | cmds: 64 | - templ generate . 65 | 66 | site: 67 | method: none 68 | desc: build and run site 69 | sources: 70 | - code/go/**/*.templ 71 | - code/go/**/*.go 72 | - code/go/site/static/**/* 73 | generates: 74 | - ./website 75 | deps: 76 | - templ 77 | - sqlc 78 | - css 79 | 80 | cmds: 81 | - go mod tidy 82 | - go build -o ./website cmd/site/main.go 83 | - ./website 84 | 85 | upx: 86 | cmds: 87 | - go build -ldflags="-s -w" -o website cmd/site/main.go 88 | - upx -9 website 89 | 90 | kill: 91 | method: none 92 | cmds: 93 | - fuser -k 4321/tcp > /dev/null 2>&1 || true 94 | 95 | default: 96 | deps: 97 | - site 98 | silent: true 99 | -------------------------------------------------------------------------------- /cmd/site/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/starfederation/1a4s-datastar/sql" 9 | "github.com/starfederation/1a4s-datastar/web" 10 | ) 11 | 12 | const port = 4321 13 | 14 | func main() { 15 | ctx := context.Background() 16 | if err := run(ctx); err != nil { 17 | log.Fatal(err) 18 | } 19 | } 20 | 21 | func run(ctx context.Context) error { 22 | db, err := sql.New(ctx) 23 | if err != nil { 24 | return fmt.Errorf("error creating database: %w", err) 25 | } 26 | defer db.Close() 27 | 28 | return web.RunBlocking(db, port)(ctx) 29 | 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/starfederation/1a4s-datastar 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/CAFxX/httpcompression v0.0.9 7 | github.com/a-h/templ v0.3.819 8 | github.com/benbjohnson/hashfs v0.2.2 9 | github.com/delaneyj/toolbelt v0.3.15 10 | github.com/go-chi/chi/v5 v5.1.0 11 | github.com/goccy/go-json v0.10.3 12 | github.com/starfederation/datastar v0.20.0 13 | github.com/valyala/bytebufferpool v1.0.0 14 | zombiezen.com/go/sqlite v1.4.0 15 | ) 16 | 17 | require ( 18 | github.com/andybalholm/brotli v1.1.1 // indirect 19 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 20 | github.com/chewxy/math32 v1.11.1 // indirect 21 | github.com/delaneyj/gostar v0.8.0 // indirect 22 | github.com/denisbrodbeck/machineid v1.0.1 // indirect 23 | github.com/dustin/go-humanize v1.0.1 // indirect 24 | github.com/go-rod/rod v0.116.2 // indirect 25 | github.com/google/uuid v1.6.0 // indirect 26 | github.com/iancoleman/strcase v0.3.0 // indirect 27 | github.com/igrmk/treemap/v2 v2.0.1 // indirect 28 | github.com/klauspost/compress v1.17.11 // indirect 29 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 30 | github.com/mattn/go-isatty v0.0.20 // indirect 31 | github.com/ncruces/go-strftime v0.1.9 // indirect 32 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 33 | github.com/rzajac/clock v0.2.0 // indirect 34 | github.com/rzajac/zflake v0.8.0 // indirect 35 | github.com/samber/lo v1.47.0 // indirect 36 | github.com/ysmood/fetchup v0.2.4 // indirect 37 | github.com/ysmood/goob v0.4.0 // indirect 38 | github.com/ysmood/got v0.40.0 // indirect 39 | github.com/ysmood/gson v0.7.3 // indirect 40 | github.com/ysmood/leakless v0.9.0 // indirect 41 | github.com/zeebo/xxh3 v1.0.2 // indirect 42 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect 43 | golang.org/x/sync v0.10.0 // indirect 44 | golang.org/x/sys v0.28.0 // indirect 45 | golang.org/x/text v0.20.0 // indirect 46 | google.golang.org/protobuf v1.35.2 // indirect 47 | modernc.org/libc v1.61.2 // indirect 48 | modernc.org/mathutil v1.6.0 // indirect 49 | modernc.org/memory v1.8.0 // indirect 50 | modernc.org/sqlite v1.34.1 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg= 2 | github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM= 3 | github.com/a-h/templ v0.3.819 h1:KDJ5jTFN15FyJnmSmo2gNirIqt7hfvBD2VXVDTySckM= 4 | github.com/a-h/templ v0.3.819/go.mod h1:iDJKJktpttVKdWoTkRNNLcllRI+BlpopJc+8au3gOUo= 5 | github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 6 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 7 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 8 | github.com/benbjohnson/hashfs v0.2.2 h1:vFZtksphM5LcnMRFctj49jCUkCc7wp3NP6INyfjkse4= 9 | github.com/benbjohnson/hashfs v0.2.2/go.mod h1:7OMXaMVo1YkfiIPxKrl7OXkUTUgWjmsAKyR+E6xDIRM= 10 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 11 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 12 | github.com/chewxy/math32 v1.11.1 h1:b7PGHlp8KjylDoU8RrcEsRuGZhJuz8haxnKfuMMRqy8= 13 | github.com/chewxy/math32 v1.11.1/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/delaneyj/gostar v0.8.0 h1:uT1JR+77P5ePL4BVTXsKNLtwUUtMAu/dNLryjEk95RA= 18 | github.com/delaneyj/gostar v0.8.0/go.mod h1:mlxRWAVbntRR2VWlpXAzt7y9HY+bQtEm/lsyFnGLx/w= 19 | github.com/delaneyj/toolbelt v0.3.15 h1:/z2H3D37nQYZ/qvr/aYoAkGnOpnLYo/GePhbWRmgfEQ= 20 | github.com/delaneyj/toolbelt v0.3.15/go.mod h1:5TCG0QBJrsVWze+mVLF8VOpqmLBHNOJLBi+2yCRkAaU= 21 | github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= 22 | github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= 23 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 24 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 25 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 26 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 27 | github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= 28 | github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= 29 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 30 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 31 | github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= 32 | github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= 33 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 34 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 35 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 36 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 37 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 38 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 39 | github.com/igrmk/treemap/v2 v2.0.1 h1:Jhy4z3yhATvYZMWCmxsnHO5NnNZBdueSzvxh6353l+0= 40 | github.com/igrmk/treemap/v2 v2.0.1/go.mod h1:PkTPvx+8OHS8/41jnnyVY+oVsfkaOUZGcr+sfonosd4= 41 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 42 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 43 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 44 | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= 45 | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 46 | github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= 47 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 48 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 49 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 50 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 51 | github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= 52 | github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 53 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 56 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 57 | github.com/rzajac/clock v0.2.0 h1:mxiL5/iTu7+pciqYGMxqUNTR+T2nxVvIdEUn3wfF4rU= 58 | github.com/rzajac/clock v0.2.0/go.mod h1:7ybePrkaEnyNk5tBHJZYZbeBU+2werzUVXn+mKT6iyw= 59 | github.com/rzajac/zflake v0.8.0 h1:EYNCn2jh16JAGuKw+NJmTz0unAH81elaSrefd3KWriU= 60 | github.com/rzajac/zflake v0.8.0/go.mod h1:uSQN20u/2bvKMkRLrqnKRqUk6tb2Ixac09WMljsSFhc= 61 | github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= 62 | github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= 63 | github.com/starfederation/datastar v0.20.0 h1:weEVa2EzZWFs7/ayqEkJwp2SB41mZMUWrnC/JQO1g2o= 64 | github.com/starfederation/datastar v0.20.0/go.mod h1:ufZzHnRgig4EWaHrGrVqF/hVe9pT3SAdwsTsWRIfgks= 65 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 66 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 67 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 68 | github.com/stretchr/testify v1.6.2-0.20201103103935-92707c0b2d50/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 69 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 70 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 71 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 72 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 73 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 74 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 75 | github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 76 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 77 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 78 | github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g= 79 | github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= 80 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 81 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 82 | github.com/ysmood/fetchup v0.2.4 h1:2kfWr/UrdiHg4KYRrxL2Jcrqx4DZYD+OtWu7WPBZl5o= 83 | github.com/ysmood/fetchup v0.2.4/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A= 84 | github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= 85 | github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= 86 | github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= 87 | github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= 88 | github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= 89 | github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= 90 | github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= 91 | github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= 92 | github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= 93 | github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= 94 | github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= 95 | github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= 96 | github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= 97 | github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 98 | github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= 99 | github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 100 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= 101 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= 102 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 103 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 104 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 105 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 106 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 108 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 109 | golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= 110 | golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= 111 | golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= 112 | golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= 113 | google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= 114 | google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 115 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 116 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 117 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 118 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 119 | modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8= 120 | modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 121 | modernc.org/ccgo/v4 v4.22.3 h1:C7AW89Zw3kygesTQWBzApwIn9ldM+cb/plrTIKq41Os= 122 | modernc.org/ccgo/v4 v4.22.3/go.mod h1:Dz7n0/UkBbH3pnYaxgi1mFSfF4REqUOZNziphZASx6k= 123 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 124 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 125 | modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= 126 | modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 127 | modernc.org/libc v1.61.2 h1:dkO4DlowfClcJYsvf/RiK6fUwvzCQTmB34bJLt0CAGQ= 128 | modernc.org/libc v1.61.2/go.mod h1:4QGjNyX3h+rn7V5oHpJY2yH0QN6frt1X+5BkXzwLPCo= 129 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 130 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 131 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 132 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 133 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 134 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 135 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 136 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 137 | modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= 138 | modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= 139 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 140 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 141 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 142 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 143 | zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= 144 | zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= 145 | -------------------------------------------------------------------------------- /sql/.gitignore: -------------------------------------------------------------------------------- 1 | zz 2 | -------------------------------------------------------------------------------- /sql/db.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "embed" 12 | 13 | "github.com/delaneyj/toolbelt" 14 | "github.com/goccy/go-json" 15 | "github.com/starfederation/1a4s-datastar/sql/zz" 16 | "github.com/valyala/bytebufferpool" 17 | "zombiezen.com/go/sqlite" 18 | ) 19 | 20 | //go:embed migrations/*.sql 21 | var migrationsFS embed.FS 22 | 23 | func New(ctx context.Context) (*toolbelt.Database, error) { 24 | migrations, err := toolbelt.MigrationsFromFS(migrationsFS, "migrations") 25 | if err != nil { 26 | return nil, fmt.Errorf("err creating migrations: %w", err) 27 | } 28 | 29 | db, err := toolbelt.NewDatabase(ctx, "pokemon.sqlite", migrations) 30 | if err != nil { 31 | return nil, fmt.Errorf("err creating database: %w", err) 32 | } 33 | 34 | var isEmpty bool 35 | if err := db.ReadTX(ctx, func(tx *sqlite.Conn) error { 36 | count, err := zz.OnceCountPokemon(tx) 37 | if err != nil { 38 | return fmt.Errorf("error counting pokemon: %w", err) 39 | } 40 | isEmpty = count == 0 41 | return nil 42 | }); err != nil { 43 | return nil, fmt.Errorf("error checking if database is empty: %w", err) 44 | } 45 | 46 | if isEmpty { 47 | log.Print("Empty database, seeding") 48 | if err := Seed(db); err != nil { 49 | return nil, fmt.Errorf("error seeding database: %w", err) 50 | } 51 | } 52 | 53 | return db, nil 54 | } 55 | 56 | func Seed(db *toolbelt.Database) error { 57 | const q = ` 58 | query GetAllPokemon { 59 | pokemon_v2_pokemon { 60 | id 61 | pokemon_v2_pokemonspecy { 62 | name 63 | } 64 | } 65 | }` 66 | b, err := json.Marshal(map[string]string{"query": q}) 67 | if err != nil { 68 | return fmt.Errorf("error marshalling query: %w", err) 69 | } 70 | r := bytes.NewReader(b) 71 | 72 | req, err := http.NewRequest("POST", "https://beta.pokeapi.co/graphql/v1beta", r) 73 | if err != nil { 74 | return fmt.Errorf("error creating request: %w", err) 75 | } 76 | req.Header.Set("Content-Type", "application/json") 77 | 78 | res, err := http.DefaultClient.Do(req) 79 | if err != nil { 80 | return fmt.Errorf("error making request: %w", err) 81 | } 82 | defer res.Body.Close() 83 | buf := bytebufferpool.Get() 84 | defer bytebufferpool.Put(buf) 85 | if _, err := buf.ReadFrom(res.Body); err != nil { 86 | return fmt.Errorf("error reading response: %w", err) 87 | } 88 | 89 | if res.StatusCode != http.StatusOK { 90 | return fmt.Errorf("error response code: %d", res.StatusCode) 91 | } 92 | 93 | type GraphQLResponse struct { 94 | Data struct { 95 | Pokemon []struct { 96 | ID int64 `json:"id"` 97 | PokemonSpecy struct { 98 | Name string `json:"name"` 99 | } `json:"pokemon_v2_pokemonspecy"` 100 | } `json:"pokemon_v2_pokemon"` 101 | } `json:"data"` 102 | } 103 | gqlRes := &GraphQLResponse{} 104 | if err := json.Unmarshal(buf.Bytes(), gqlRes); err != nil { 105 | return fmt.Errorf("error unmarshalling response: %w", err) 106 | } 107 | 108 | if err := db.WriteTX(context.Background(), func(tx *sqlite.Conn) error { 109 | insert := zz.CreatePokemon(tx) 110 | now := time.Now() 111 | for _, p := range gqlRes.Data.Pokemon { 112 | // https://github.com/t3dotgg/1app5stacks/blob/main/go-graphql-spa-version/go-gql-server/main.go#L144C108-L144C124 113 | if p.ID >= 1025 { 114 | continue 115 | } 116 | if err := insert.Run(&zz.PokemonModel{ 117 | Id: p.ID, 118 | Name: p.PokemonSpecy.Name, 119 | DexId: p.ID, 120 | InsertedAt: now, 121 | UpdatedAt: now, 122 | }); err != nil { 123 | return fmt.Errorf("error inserting pokemon: %w", err) 124 | } 125 | } 126 | return nil 127 | }); err != nil { 128 | return fmt.Errorf("error inserting pokemon: %w", err) 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func RandomResToPokemonModel(p zz.RandomPokemonRes) *zz.PokemonModel { 135 | return &zz.PokemonModel{ 136 | Id: p.Id, 137 | Name: p.Name, 138 | DexId: p.DexId, 139 | UpVotes: p.UpVotes, 140 | DownVotes: p.DownVotes, 141 | InsertedAt: p.InsertedAt, 142 | UpdatedAt: p.UpdatedAt, 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /sql/migrations/2411181100_initial.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS pokemon ( 2 | id INTEGER PRIMARY KEY, 3 | name TEXT NOT NULL, 4 | dex_id INTEGER NOT NULL, 5 | up_votes INTEGER NOT NULL, 6 | down_votes INTEGER NOT NULL, 7 | inserted_at DATETIME NOT NULL, 8 | updated_at DATETIME NOT NULL 9 | ); 10 | 11 | CREATE INDEX IF NOT EXISTS idx_pokemon_dex_id ON pokemon(dex_id); -------------------------------------------------------------------------------- /sql/queries/queries.sql: -------------------------------------------------------------------------------- 1 | -- name: RandomPokemon :many 2 | SELECT * FROM pokemon ORDER BY random() LIMIT @limit; 3 | 4 | -- name: UpvotePokemon :exec 5 | UPDATE pokemon 6 | SET 7 | up_votes = up_votes + 1, 8 | updated_at = @updated_at 9 | WHERE id = @id; 10 | 11 | -- name: DownvotePokemon :exec 12 | UPDATE pokemon 13 | SET 14 | down_votes = down_votes + 1, 15 | updated_at = @updated_at 16 | WHERE id = @id; 17 | 18 | -- name: AllIDs :many 19 | SELECT id FROM pokemon 20 | WHERE id < 1025; 21 | 22 | -- name: Results :many 23 | SELECT 24 | *, 25 | 10000 * up_votes / total_votes as win_percentage 26 | FROM ( 27 | SELECT 28 | name, id, dex_id, 29 | up_votes, down_votes, 30 | up_votes + down_votes as total_votes 31 | FROM 32 | pokemon 33 | WHERE id < 1025 34 | ) as subquery 35 | ORDER BY up_votes DESC, win_percentage DESC; -------------------------------------------------------------------------------- /sql/sqlc.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | plugins: 4 | - name: zz 5 | process: 6 | cmd: sqlc-gen-zombiezen 7 | 8 | sql: 9 | - engine: "sqlite" 10 | queries: "./queries" 11 | schema: "./migrations" 12 | codegen: 13 | - out: zz 14 | plugin: zz -------------------------------------------------------------------------------- /web/gen/css/.gitignore: -------------------------------------------------------------------------------- 1 | tailwindcli 2 | -------------------------------------------------------------------------------- /web/gen/css/site.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply bg-gray-950 text-gray-100; 7 | } -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /web/routes_home.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/delaneyj/toolbelt" 8 | "github.com/go-chi/chi/v5" 9 | "github.com/starfederation/1a4s-datastar/sql/zz" 10 | datastar "github.com/starfederation/datastar/code/go/sdk" 11 | "zombiezen.com/go/sqlite" 12 | ) 13 | 14 | func setupHomeRoutes(r chi.Router, db *toolbelt.Database) error { 15 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 16 | http.Redirect(w, r, "/vote", http.StatusSeeOther) 17 | }) 18 | 19 | r.Get("/prefetch", func(w http.ResponseWriter, r *http.Request) { 20 | sse := datastar.NewSSE(w, r) 21 | 22 | var ids []int64 23 | if err := db.ReadTX(r.Context(), func(tx *sqlite.Conn) (err error) { 24 | ids, err = zz.OnceAllIds(tx) 25 | return err 26 | }); err != nil { 27 | sse.ConsoleError(err) 28 | return 29 | } 30 | 31 | urls := make([]string, len(ids)) 32 | for i, id := range ids { 33 | urls[i] = fmt.Sprintf(pokemonSpriteURLFormat, id) 34 | } 35 | sse.Prefetch(urls...) 36 | }) 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /web/routes_results.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/delaneyj/toolbelt" 7 | "github.com/go-chi/chi/v5" 8 | "github.com/starfederation/1a4s-datastar/sql/zz" 9 | datastar "github.com/starfederation/datastar/code/go/sdk" 10 | "zombiezen.com/go/sqlite" 11 | ) 12 | 13 | func setupResultsRoutes(r chi.Router, db *toolbelt.Database) error { 14 | r.Route("/results", func(resultsRouter chi.Router) { 15 | resultsRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { 16 | ResultsPage().Render(r.Context(), w) 17 | }) 18 | 19 | resultsRouter.Get("/rows", func(w http.ResponseWriter, r *http.Request) { 20 | sse := datastar.NewSSE(w, r) 21 | ctx := r.Context() 22 | var rows []zz.ResultsRes 23 | if err := db.ReadTX(ctx, func(tx *sqlite.Conn) (err error) { 24 | rows, err = zz.OnceResults(tx) 25 | return err 26 | }); err != nil { 27 | sse.ConsoleError(err) 28 | return 29 | } 30 | sse.MergeFragmentTempl(resultRows(rows...)) 31 | }) 32 | }) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /web/routes_results.templ: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "github.com/starfederation/1a4s-datastar/sql/zz" 6 | "strconv" 7 | ) 8 | 9 | templ ResultsPage() { 10 | @Page() { 11 |
15 |
16 | 17 |
18 |
19 | } 20 | } 21 | 22 | templ resultRows(rows ...zz.ResultsRes) { 23 |
24 | for i, row := range rows { 25 |
26 |
27 | #{ strconv.Itoa(i+1) } 28 |
29 | @pokemonSprite(row.DexId, "w-20 h-20") 30 |
31 |
#{ strconv.FormatInt(row.Id, 10) }
32 |

{ row.Name }

33 |
34 |
35 |
36 | { fmt.Sprintf("%0.2f%%", float64(row.WinPercentage)/100) } 37 |
38 |
39 | { fmt.Sprintf("%dW - %dL", row.UpVotes, row.DownVotes) } 40 |
41 |
42 |
43 | } 44 |
45 | } 46 | -------------------------------------------------------------------------------- /web/routes_vote.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/delaneyj/toolbelt" 10 | "github.com/go-chi/chi/v5" 11 | "github.com/starfederation/1a4s-datastar/sql" 12 | "github.com/starfederation/1a4s-datastar/sql/zz" 13 | datastar "github.com/starfederation/datastar/code/go/sdk" 14 | "zombiezen.com/go/sqlite" 15 | ) 16 | 17 | type PokemonBattle struct { 18 | UpvoteID int64 `json:"upvoteId"` 19 | DownvoteID int64 `json:"downvoteId"` 20 | } 21 | 22 | func setupVoteRoutes(r chi.Router, db *toolbelt.Database) error { 23 | r.Route("/vote", func(voteRouter chi.Router) { 24 | 25 | randomBattle := func(tx *sqlite.Conn) (left, right *zz.PokemonModel, err error) { 26 | res, err := zz.OnceRandomPokemon(tx, 2) 27 | if err != nil { 28 | return nil, nil, fmt.Errorf("error getting random pokemon: %w", err) 29 | } 30 | left = sql.RandomResToPokemonModel(res[0]) 31 | right = sql.RandomResToPokemonModel(res[1]) 32 | return left, right, nil 33 | } 34 | 35 | voteRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { 36 | var ( 37 | left, right *zz.PokemonModel 38 | ) 39 | if err := db.ReadTX(r.Context(), func(tx *sqlite.Conn) (err error) { 40 | left, right, err = randomBattle(tx) 41 | return err 42 | }); err != nil { 43 | http.Error(w, err.Error(), http.StatusInternalServerError) 44 | return 45 | } 46 | 47 | VotePage(left, right).Render(r.Context(), w) 48 | }) 49 | 50 | voteRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { 51 | battle := &PokemonBattle{} 52 | if err := datastar.ReadSignals(r, battle); err != nil { 53 | http.Error(w, err.Error(), http.StatusBadRequest) 54 | return 55 | } 56 | sse := datastar.NewSSE(w, r) 57 | 58 | now := time.Now() 59 | var left, right *zz.PokemonModel 60 | if err := db.WriteTX(r.Context(), func(tx *sqlite.Conn) (err error) { 61 | upvoteErr := zz.OnceUpvotePokemon(tx, zz.UpvotePokemonParams{ 62 | Id: battle.UpvoteID, 63 | UpdatedAt: now, 64 | }) 65 | downvoteErr := zz.OnceDownvotePokemon(tx, zz.DownvotePokemonParams{ 66 | Id: battle.DownvoteID, 67 | UpdatedAt: now, 68 | }) 69 | if err := errors.Join(upvoteErr, downvoteErr); err != nil { 70 | return fmt.Errorf("error voting: %w", err) 71 | } 72 | 73 | // Get new battle 74 | left, right, err = randomBattle(tx) 75 | return err 76 | }); err != nil { 77 | http.Error(w, err.Error(), http.StatusInternalServerError) 78 | return 79 | } 80 | 81 | sse.MergeFragmentTempl(voteContainer(left, right)) 82 | }) 83 | 84 | }) 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /web/routes_vote.templ: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "github.com/starfederation/1a4s-datastar/sql/zz" 6 | "strconv" 7 | ) 8 | 9 | const pokemonSpriteURLFormat = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/%d.png` 10 | 11 | templ VotePage(left, right *zz.PokemonModel, ids ...int64) { 12 | @Page() { 13 |
14 | @voteContainer(left, right) 15 | } 16 | } 17 | 18 | templ voteContainer(left, right *zz.PokemonModel) { 19 |
24 | @pokemonFragment(left, right) 25 | @pokemonFragment(right, left) 26 |
27 | } 28 | 29 | templ pokemonFragment(p, other *zz.PokemonModel) { 30 | {{ 31 | idStr := strconv.Itoa(int(p.Id)) 32 | voteURL := fmt.Sprintf("$upvoteId=%d;$downvoteId=%d;@post('/vote')", p.Id, other.Id) 33 | }} 34 |
35 | @pokemonSprite(p.Id, "w-64 h-64") 36 |
37 | #{ idStr } 38 |

{ p.Name }

39 | 45 |
46 |
47 | } 48 | 49 | templ pokemonSprite(id int64, class string) { 50 | { 55 | } 56 | -------------------------------------------------------------------------------- /web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/CAFxX/httpcompression" 12 | "github.com/benbjohnson/hashfs" 13 | "github.com/delaneyj/toolbelt" 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | ) 17 | 18 | //go:embed static/* 19 | var staticFS embed.FS 20 | 21 | var ( 22 | staticSys = hashfs.NewFS(staticFS) 23 | compressionMiddleware func(http.Handler) http.Handler 24 | ) 25 | 26 | func staticPath(path string) string { 27 | return "/" + staticSys.HashName("static/"+path) 28 | } 29 | 30 | func RunBlocking(db *toolbelt.Database, port int) toolbelt.CtxErrFunc { 31 | return func(ctx context.Context) (err error) { 32 | compressionMiddleware, err = httpcompression.DefaultAdapter() 33 | if err != nil { 34 | return fmt.Errorf("error creating compression middleware: %w", err) 35 | } 36 | 37 | router := chi.NewRouter() 38 | router.Use(middleware.Recoverer, compressionMiddleware) 39 | router.Handle("/static/*", hashfs.FileServer(staticSys)) 40 | 41 | if err := errors.Join( 42 | setupHomeRoutes(router, db), 43 | setupVoteRoutes(router, db), 44 | setupResultsRoutes(router, db), 45 | ); err != nil { 46 | return fmt.Errorf("error setting up routes: %w", err) 47 | } 48 | 49 | srv := &http.Server{ 50 | Addr: fmt.Sprintf(":%d", port), 51 | Handler: router, 52 | } 53 | go func() { 54 | <-ctx.Done() 55 | srv.Shutdown(context.Background()) 56 | }() 57 | 58 | log.Printf("Hosting on http://localhost:%d", port) 59 | return srv.ListenAndServe() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /web/shared.templ: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | templ Page() { 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Roundest (Go + Datastar Version) 12 | 13 | 14 |
15 |
16 | 31 |
32 |
33 | { children... } 34 |
35 | 44 |
45 | 46 | 47 | } 48 | -------------------------------------------------------------------------------- /web/static/.gitignore: -------------------------------------------------------------------------------- 1 | site.css -------------------------------------------------------------------------------- /web/static/datastar.js: -------------------------------------------------------------------------------- 1 | // Datastar v1.0.0-beta1 2 | var qe=/🖕JS_DS🚀/.source,le=qe.slice(0,5),we=qe.slice(4),k="datastar";var We="Datastar-Request",$e="1.0.0-beta1",ue=300;var Ue="type module",ce=!1,Ge=!1,Be=!0,L={Morph:"morph",Inner:"inner",Outer:"outer",Prepend:"prepend",Append:"append",Before:"before",After:"after",UpsertAttributes:"upsertAttributes"},je=L.Morph,C={MergeFragments:"datastar-merge-fragments",MergeSignals:"datastar-merge-signals",RemoveFragments:"datastar-remove-fragments",RemoveSignals:"datastar-remove-signals",ExecuteScript:"datastar-execute-script"};var En="computed",Ke={type:1,name:En,keyReq:1,valReq:1,onLoad:({key:t,signals:e,genRX:n})=>{let r=n();e.setComputed(t,r)}};var q=t=>t.trim()==="true",j=t=>t.replace(/[A-Z]+(?![a-z])|[A-Z]/g,(e,n)=>(n?"-":"")+e.toLowerCase()),Je=t=>t.replace(/(?:^\w|[A-Z]|\b\w)/g,(e,n)=>n===0?e.toLowerCase():e.toUpperCase()).replace(/\s+/g,""),fe=t=>new Function(`return Object.assign({}, ${t})`)(),W=t=>t.startsWith("$")?t.slice(1):t;var ze={type:1,name:"signals",valReq:1,removeOnLoad:!0,onLoad:t=>{let{key:e,genRX:n,signals:r,mods:i}=t,o=i.has("ifmissing");if(e!==""&&!o)r.setValue(e,n()());else{let s=fe(t.value);t.value=JSON.stringify(s);let l=n()();r.merge(l,o)}}};var Xe={type:1,name:"star",keyReq:2,valReq:2,onLoad:()=>{alert("YOU ARE PROBABLY OVERCOMPLICATING IT")}};var Q=class{#e=0;#t;constructor(e=k){this.#t=e}with(e){if(typeof e=="string")for(let n of e.split(""))this.with(n.charCodeAt(0));else this.#e=(this.#e<<5)-this.#e+e;return this}reset(){return this.#e=0,this}get value(){return this.#t+Math.abs(this.#e).toString(36)}};function Ye(t){if(t.id)return t.id;let e=new Q,n=t;for(;n.parentNode;){if(n.id){e.with(n.id);break}if(n===n.ownerDocument.documentElement)e.with(n.tagName);else{for(let r=1,i=t;i.previousElementSibling;i=i.previousElementSibling,r++)e.with(r);n=n.parentNode}n=n.parentNode}return e.value}function Ze(t,e){let n=new MutationObserver(r=>{for(let i of r)for(let o of i.removedNodes)if(o===t){n.disconnect(),e();return}});n.observe(t.parentNode,{childList:!0})}var Sn="https://data-star.dev/errors";var c=(t,e)=>{let n=new Error;t=t.charAt(0).toUpperCase()+t.slice(1),n.name=`error ${t}`;let r=`${Sn}/${t}?${new URLSearchParams(e)}`;return n.message=`for more info see ${r}`,n};var Tn=Symbol.for("preact-signals"),V=1,K=2,te=4,z=8,de=16,J=32;function Me(){pe++}function Ne(){if(pe>1){pe--;return}let t,e=!1;for(;ee!==void 0;){let n=ee;for(ee=void 0,Pe++;n!==void 0;){let r=n._nextBatchedEffect;if(n._nextBatchedEffect=void 0,n._flags&=~K,!(n._flags&z)&&et(n))try{n._callback()}catch(i){e||(t=i,e=!0)}n=r}}if(Pe=0,pe--,e)throw c("BatchError, error",{error:t})}var E;var ee,pe=0,Pe=0,me=0;function Qe(t){if(E===void 0)return;let e=t._node;if(e===void 0||e._target!==E)return e={_version:0,_source:t,_prevSource:E._sources,_nextSource:void 0,_target:E,_prevTarget:void 0,_nextTarget:void 0,_rollbackNode:e},E._sources!==void 0&&(E._sources._nextSource=e),E._sources=e,t._node=e,E._flags&J&&t._subscribe(e),e;if(e._version===-1)return e._version=0,e._nextSource!==void 0&&(e._nextSource._prevSource=e._prevSource,e._prevSource!==void 0&&(e._prevSource._nextSource=e._nextSource),e._prevSource=E._sources,e._nextSource=void 0,E._sources._nextSource=e,E._sources=e),e}function x(t){this._value=t,this._version=0,this._node=void 0,this._targets=void 0}x.prototype.brand=Tn;x.prototype._refresh=()=>!0;x.prototype._subscribe=function(t){this._targets!==t&&t._prevTarget===void 0&&(t._nextTarget=this._targets,this._targets!==void 0&&(this._targets._prevTarget=t),this._targets=t)};x.prototype._unsubscribe=function(t){if(this._targets!==void 0){let e=t._prevTarget,n=t._nextTarget;e!==void 0&&(e._nextTarget=n,t._prevTarget=void 0),n!==void 0&&(n._prevTarget=e,t._nextTarget=void 0),t===this._targets&&(this._targets=n)}};x.prototype.subscribe=function(t){return ge(()=>{let e=this.value,n=E;E=void 0;try{t(e)}finally{E=n}})};x.prototype.valueOf=function(){return this.value};x.prototype.toString=function(){return`${this.value}`};x.prototype.toJSON=function(){return this.value};x.prototype.peek=function(){let t=E;E=void 0;try{return this.value}finally{E=t}};Object.defineProperty(x.prototype,"value",{get(){let t=Qe(this);return t!==void 0&&(t._version=this._version),this._value},set(t){if(t!==this._value){if(Pe>100)throw c("SignalCycleDetected");this._value=t,this._version++,me++,Me();try{for(let e=this._targets;e!==void 0;e=e._nextTarget)e._target._notify()}finally{Ne()}}}});function et(t){for(let e=t._sources;e!==void 0;e=e._nextSource)if(e._source._version!==e._version||!e._source._refresh()||e._source._version!==e._version)return!0;return!1}function tt(t){for(let e=t._sources;e!==void 0;e=e._nextSource){let n=e._source._node;if(n!==void 0&&(e._rollbackNode=n),e._source._node=e,e._version=-1,e._nextSource===void 0){t._sources=e;break}}}function nt(t){let e=t._sources,n;for(;e!==void 0;){let r=e._prevSource;e._version===-1?(e._source._unsubscribe(e),r!==void 0&&(r._nextSource=e._nextSource),e._nextSource!==void 0&&(e._nextSource._prevSource=r)):n=e,e._source._node=e._rollbackNode,e._rollbackNode!==void 0&&(e._rollbackNode=void 0),e=r}t._sources=n}function U(t){x.call(this,void 0),this._fn=t,this._sources=void 0,this._globalVersion=me-1,this._flags=te}U.prototype=new x;U.prototype._refresh=function(){if(this._flags&=~K,this._flags&V)return!1;if((this._flags&(te|J))===J||(this._flags&=~te,this._globalVersion===me))return!0;if(this._globalVersion=me,this._flags|=V,this._version>0&&!et(this))return this._flags&=~V,!0;let t=E;try{tt(this),E=this;let e=this._fn();(this._flags&de||this._value!==e||this._version===0)&&(this._value=e,this._flags&=~de,this._version++)}catch(e){this._value=e,this._flags|=de,this._version++}return E=t,nt(this),this._flags&=~V,!0};U.prototype._subscribe=function(t){if(this._targets===void 0){this._flags|=te|J;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._subscribe(e)}x.prototype._subscribe.call(this,t)};U.prototype._unsubscribe=function(t){if(this._targets!==void 0&&(x.prototype._unsubscribe.call(this,t),this._targets===void 0)){this._flags&=~J;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e)}};U.prototype._notify=function(){if(!(this._flags&K)){this._flags|=te|K;for(let t=this._targets;t!==void 0;t=t._nextTarget)t._target._notify()}};Object.defineProperty(U.prototype,"value",{get(){if(this._flags&V)throw c("SignalCycleDetected");let t=Qe(this);if(this._refresh(),t!==void 0&&(t._version=this._version),this._flags&de)throw c("GetComputedError",{value:this._value});return this._value}});function rt(t){return new U(t)}function it(t){let e=t._cleanup;if(t._cleanup=void 0,typeof e=="function"){Me();let n=E;E=void 0;try{e()}catch(r){throw t._flags&=~V,t._flags|=z,Ce(t),c("CleanupEffectError",{error:r})}finally{E=n,Ne()}}}function Ce(t){for(let e=t._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e);t._fn=void 0,t._sources=void 0,it(t)}function An(t){if(E!==this)throw c("EndEffectError");nt(this),E=t,this._flags&=~V,this._flags&z&&Ce(this),Ne()}function ne(t){this._fn=t,this._cleanup=void 0,this._sources=void 0,this._nextBatchedEffect=void 0,this._flags=J}ne.prototype._callback=function(){let t=this._start();try{if(this._flags&z||this._fn===void 0)return;let e=this._fn();typeof e=="function"&&(this._cleanup=e)}finally{t()}};ne.prototype._start=function(){if(this._flags&V)throw c("SignalCycleDetected");this._flags|=V,this._flags&=~z,it(this),tt(this),Me();let t=E;return E=this,An.bind(this,t)};ne.prototype._notify=function(){this._flags&K||(this._flags|=K,this._nextBatchedEffect=ee,ee=this)};ne.prototype._dispose=function(){this._flags|=z,this._flags&V||Ce(this)};function ge(t){let e=new ne(t);try{e._callback()}catch(n){throw e._dispose(),n}return e._dispose.bind(e)}function ot(t,e=!1){let n={};for(let r in t)if(Object.hasOwn(t,r)){if(e&&r.startsWith("_"))continue;let i=t[r];i instanceof x?n[r]=i.value:n[r]=ot(i)}return n}function st(t,e,n=!1){for(let r in e)if(Object.hasOwn(e,r)){if(r.match(/\_\_+/))throw c("InvalidSignalKey",{key:r});let i=e[r];if(i instanceof Object&&!Array.isArray(i))t[r]||(t[r]={}),st(t[r],i,n);else{if(Object.hasOwn(t,r)){if(n)continue;let s=t[r];if(s instanceof x){s.value=i;continue}}t[r]=new x(i)}}}function at(t,e){for(let n in t)if(Object.hasOwn(t,n)){let r=t[n];r instanceof x?e(n,r):at(r,(i,o)=>{e(`${n}.${i}`,o)})}}function _n(t,...e){let n={};for(let r of e){let i=r.split("."),o=t,s=n;for(let l=0;ln());this.setSignal(e,r)}value(e){return this.signal(e)?.value}setValue(e,n){let r=this.upsertIfMissing(e,n);r.value=n}upsertIfMissing(e,n){let r=e.split("."),i=this.#e;for(let l=0;lge(o),actions:this.#r,apply:this.apply.bind(this),cleanup:this.#i.bind(this)})}}this.apply(document.body)}apply(e){let n=new Set;this.#t.forEach((r,i)=>{this.#s(e,o=>{if(!("starIgnore"in o.dataset)){i||this.#i(o);for(let s in o.dataset){if(!s.startsWith(r.name))continue;let a=s.slice(r.name.length),[l,...f]=a.split(/\_\_+/),u=l.length>0;u&&(l.startsWith("-_")?l=l.slice(1):l=l[0].toLowerCase()+l.slice(1));let d=`${o.dataset[s]}`||"",y=d.length>0,m=r.keyReq||0;if(u){if(m===2)throw c(`${r.name}KeyNotAllowed`,{key:l})}else if(m===1)throw c(`${r.name}KeyRequired`);let p=r.valReq||0;if(y){if(p===2)throw c(`${r.name}ValueNotAllowed`,{rawValue:d})}else if(p===1)throw c(`${r.name}ValueRequired`);if(m===3||p===3){if(u&&y)throw c(`${r.name}KeyAndValueProvided`);if(!u&&!y)throw c(`${r.name}KeyOrValueRequired`)}o.id.length||(o.id=Ye(o));let S=new Map;for(let h of f){let[b,...w]=h.split(".");S.set(Je(b),new Set(w.map(P=>P.toLowerCase())))}let _=this,A={get signals(){return _.#e},effect:h=>ge(h),apply:_.apply.bind(_),cleanup:_.#i.bind(_),actions:_.#r,genRX:()=>this.#l(A,...r.argNames||[]),el:o,rawKey:s,rawValue:d,key:l,value:d,mods:S};n.clear();let D=[...r.macros?.pre||[],...this.#o,...r.macros?.post||[]];for(let h of D)n.has(h)||(n.add(h),A.value=h.fn(A,A.value));let T=r.onLoad(A);T&&(this.#n.has(o)||this.#n.set(o,{id:o.id,set:new Set}),this.#n.get(o)?.set.add(T)),r?.removeOnLoad&&delete o.dataset[s]}}})})}#l(e,...n){let r=e.value.split(/;|\n/).map(p=>p.trim()).filter(p=>p!==""),i=r.length-1;r[i].startsWith("return")||(r[i]=`return (${r[i]});`);let s=r.join(`; 3 | `).trim(),a=new Map;for(let p of s.matchAll(xn)){let S=p[1],_=new Q("dsEscaped").with(S).value;a.set(_,S),s=s.replace(le+S+we,_)}let l=/@(\w*)\(/gm,f=s.matchAll(l),u=new Set;for(let p of f)u.add(p[1]);let d=new RegExp(`@(${Object.keys(this.#r).join("|")})\\(`,"gm");s=s.replaceAll(d,"ctx.actions.$1.fn(ctx,");let y=new Array;if(e.signals.walk(p=>y.push(p)),y.length){let p=new RegExp(`\\$(${y.join("|")})`,"gm");s=s.replaceAll(p,"ctx.signals.signal('$1').value")}for(let[p,S]of a)s=s.replace(p,S);let m=`return (()=> { 4 | ${s} 5 | })()`;try{let p=new Function("ctx",...n,m);return(...S)=>p(e,...S)}catch(p){throw c("GeneratingExpressionFailed",{error:p,fnContent:m})}}#s(e,n){if(!e||!(e instanceof HTMLElement||e instanceof SVGElement))return null;n(e);let r=e.firstElementChild;for(;r;)this.#s(r,n),r=r.nextElementSibling}#i(e){let n=this.#n.get(e);if(n){for(let r of n.set)r();this.#n.delete(e)}}};var lt=new ye;lt.load(Xe,ze,Ke);var De=lt;async function Rn(t,e){let n=t.getReader(),r;for(;!(r=await n.read()).done;)e(r.value)}function wn(t){let e,n,r,i=!1;return function(s){e===void 0?(e=s,n=0,r=-1):e=Mn(e,s);let a=e.length,l=0;for(;n0){let l=i.decode(s.subarray(0,a)),f=a+(s[a+1]===32?2:1),u=i.decode(s.subarray(f));switch(l){case"data":r.data=r.data?`${r.data} 6 | ${u}`:u;break;case"event":r.event=u;break;case"id":t(r.id=u);break;case"retry":{let d=Number.parseInt(u,10);Number.isNaN(d)||e(r.retry=d);break}}}}}function Mn(t,e){let n=new Uint8Array(t.length+e.length);return n.set(t),n.set(e,t.length),n}function ut(){return{data:"",event:"",id:"",retry:void 0}}var Nn="text/event-stream",ct="last-event-id";function ft(t,{signal:e,headers:n,onopen:r,onmessage:i,onclose:o,onerror:s,openWhenHidden:a,fetch:l,retryInterval:f=1e3,retryScaler:u=2,retryMaxWaitMs:d=3e4,retryMaxCount:y=10,...m}){return new Promise((p,S)=>{let _=0,A={...n};A.accept||(A.accept=Nn);let D;function T(){D.abort(),document.hidden||g()}a||document.addEventListener("visibilitychange",T);let h=0;function b(){document.removeEventListener("visibilitychange",T),window.clearTimeout(h),D.abort()}e?.addEventListener("abort",()=>{b(),p()});let w=l??window.fetch,P=r??function(){};async function g(){D=new AbortController;try{let N=await w(t,{...m,headers:A,signal:D.signal});await P(N),await Rn(N.body,wn(Pn(R=>{R?A[ct]=R:delete A[ct]},R=>{f=R},i))),o?.(),b(),p()}catch(N){if(!D.signal.aborted)try{let R=s?.(N)??f;window.clearTimeout(h),h=window.setTimeout(g,R),f*=u,f=Math.min(f,d),_++,_>=y?(b(),S(c("SseMaxRetries",{retryMaxCount:y}))):console.error(`Datastar failed to reach ${m.method}: ${t.toString()} retry in ${R}ms`)}catch(R){b(),S(R)}}}g()})}var X=`${k}-sse`,Ie=`${k}-settling`,G=`${k}-swapping`,ve="started",be="finished",dt="error";function O(t,e){document.addEventListener(X,n=>{if(n.detail.type!==t)return;let{argsRaw:r}=n.detail;e(r)})}function Ee(t,e){document.dispatchEvent(new CustomEvent(X,{detail:{type:t,argsRaw:e}}))}var pt=t=>`${t}`.includes("text/event-stream"),F=async(t,e,n)=>{let{el:{id:r},el:i,signals:o}=t,{method:s,headers:a,contentType:l,includeLocal:f,selector:u,openWhenHidden:d,retryInterval:y,retryScaler:m,retryMaxWaitMs:p,retryMaxCount:S,abort:_}=Object.assign({method:"GET",headers:{},contentType:"json",includeLocal:!1,selector:null,openWhenHidden:!1,retryInterval:1e3,retryScaler:2,retryMaxWaitMs:3e4,retryMaxCount:10,abort:void 0},n),A=s.toUpperCase(),D=()=>{};try{if(Ee(ve,{elId:r}),!e?.length)throw c("NoUrlProvided");let T={};T[We]=!0,l==="json"&&(T["Content-Type"]="application/json");let h=Object.assign({},T,a),b={method:A,headers:h,openWhenHidden:d,retryInterval:y,retryScaler:m,retryMaxWaitMs:p,retryMaxCount:S,signal:_,onopen:async g=>{if(g.status>=400){let N=g.status.toString();Ee(dt,{status:N})}},onmessage:g=>{if(!g.event.startsWith(k))return;let N=g.event,R={},I=g.data.split(` 7 | `);for(let Z of I){let se=Z.indexOf(" "),He=Z.slice(0,se),ae=R[He];ae||(ae=[],R[He]=ae);let bn=Z.slice(se+1).trim();ae.push(bn)}let H={};for(let[Z,se]of Object.entries(R))H[Z]=se.join(` 8 | `);Ee(N,H)},onerror:g=>{if(pt(g))throw c("InvalidContentType",{url:e,error:g});g&&console.error(g.message)}},w=new URL(e,window.location.origin),P=new URLSearchParams(w.search);if(l==="json"){let g=o.JSON(!1,!f);A==="GET"?P.set(k,g):b.body=g}else if(l==="form"){let g=u?document.querySelector(u):i.closest("form");if(g===null)throw u?c("SseFormNotFound",{selector:u}):c("SseClosestFormNotFound");if(i!==g){let R=I=>I.preventDefault();g.addEventListener("submit",R),D=()=>g.removeEventListener("submit",R)}if(!g.checkValidity()){g.reportValidity(),D();return}let N=new FormData(g);if(A==="GET"){let R=new URLSearchParams(N);for(let[I,H]of R)P.set(I,H)}else b.body=N}else throw c("SseInvalidContentType",{contentType:l});w.search=P.toString();try{await ft(w.toString(),b)}catch(g){if(!pt(g))throw c("SseFetchFailed",{method:A,url:e,error:g})}}finally{Ee(be,{elId:r}),D()}};var mt={type:3,name:"delete",fn:async(t,e,n)=>F(t,e,{...n,method:"DELETE"})};var gt={type:3,name:"get",fn:async(t,e,n)=>F(t,e,{...n,method:"GET"})};var ht={type:3,name:"patch",fn:async(t,e,n)=>F(t,e,{...n,method:"PATCH"})};var yt={type:3,name:"post",fn:async(t,e,n)=>F(t,e,{...n,method:"POST"})};var vt={type:3,name:"put",fn:async(t,e,n)=>F(t,e,{...n,method:"PUT"})};var bt={type:1,name:"indicator",keyReq:3,valReq:3,onLoad:({value:t,signals:e,el:n,key:r})=>{let i=r||W(t),o=e.upsertIfMissing(i,!1),s=a=>{let{type:l,argsRaw:{elId:f}}=a.detail;if(f===n.id)switch(l){case ve:o.value=!0;break;case be:o.value=!1;break}};return document.addEventListener(X,s),()=>{document.removeEventListener(X,s)}}};var Et={type:2,name:C.ExecuteScript,onGlobalInit:async()=>{O(C.ExecuteScript,({autoRemove:t=`${Be}`,attributes:e=Ue,script:n})=>{let r=q(t);if(!n?.length)throw c("NoScriptProvided");let i=document.createElement("script");for(let o of e.split(` 9 | `)){let s=o.indexOf(" "),a=s?o.slice(0,s):o,l=s?o.slice(s):"";i.setAttribute(a.trim(),l.trim())}i.text=n,document.head.appendChild(i),r&&i.remove()})}};var re=document,Y=!!re.startViewTransition;var Te=new WeakSet;function _t(t,e,n={}){t instanceof Document&&(t=t.documentElement);let r;typeof e=="string"?r=kn(e):r=e;let i=Vn(r),o=Dn(t,i,n);return xt(t,i,o)}function xt(t,e,n){if(n.head.block){let r=t.querySelector("head"),i=e.querySelector("head");if(r&&i){let o=wt(i,r,n);Promise.all(o).then(()=>{xt(t,e,Object.assign(n,{head:{block:!1,ignore:!0}}))});return}}if(n.morphStyle==="innerHTML")return Rt(e,t,n),t.children;if(n.morphStyle==="outerHTML"||n.morphStyle==null){let r=Fn(e,t,n);if(!r)throw c("NoBestMatchFound",{old:t,new:e});let i=r?.previousSibling,o=r?.nextSibling,s=Ae(t,r,n);return r?On(i,s,o):[]}throw c("InvalidMorphStyle",{style:n.morphStyle})}function Ae(t,e,n){if(!(n.ignoreActive&&t===document.activeElement))if(e==null){if(n.callbacks.beforeNodeRemoved(t)===!1)return;t.remove(),n.callbacks.afterNodeRemoved(t);return}else{if(_e(t,e))return n.callbacks.beforeNodeMorphed(t,e)===!1?void 0:(t instanceof HTMLHeadElement&&n.head.ignore||(e instanceof HTMLHeadElement&&t instanceof HTMLHeadElement&&n.head.style!==L.Morph?wt(e,t,n):(Cn(e,t),Rt(e,t,n))),n.callbacks.afterNodeMorphed(t,e),t);if(n.callbacks.beforeNodeRemoved(t)===!1||n.callbacks.beforeNodeAdded(e)===!1)return;if(!t.parentElement)throw c("NoParentElementFound",{oldNode:t});return t.parentElement.replaceChild(e,t),n.callbacks.afterNodeAdded(e),n.callbacks.afterNodeRemoved(t),e}}function Rt(t,e,n){let r=t.firstChild,i=e.firstChild,o;for(;r;){if(o=r,r=o.nextSibling,i==null){if(n.callbacks.beforeNodeAdded(o)===!1)return;e.appendChild(o),n.callbacks.afterNodeAdded(o),B(n,o);continue}if(Pt(o,i,n)){Ae(i,o,n),i=i.nextSibling,B(n,o);continue}let s=In(t,e,o,i,n);if(s){i=St(i,s,n),Ae(s,o,n),B(n,o);continue}let a=Ln(t,o,i,n);if(a){i=St(i,a,n),Ae(a,o,n),B(n,o);continue}if(n.callbacks.beforeNodeAdded(o)===!1)return;e.insertBefore(o,i),n.callbacks.afterNodeAdded(o),B(n,o)}for(;i!==null;){let s=i;i=i.nextSibling,Mt(s,n)}}function Cn(t,e){let n=t.nodeType;if(n===1){for(let r of t.attributes)e.getAttribute(r.name)!==r.value&&e.setAttribute(r.name,r.value);for(let r of e.attributes)t.hasAttribute(r.name)||e.removeAttribute(r.name)}if((n===Node.COMMENT_NODE||n===Node.TEXT_NODE)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),t instanceof HTMLInputElement&&e instanceof HTMLInputElement&&t.type!=="file")e.value=t.value||"",Se(t,e,"value"),Se(t,e,"checked"),Se(t,e,"disabled");else if(t instanceof HTMLOptionElement)Se(t,e,"selected");else if(t instanceof HTMLTextAreaElement&&e instanceof HTMLTextAreaElement){let r=t.value,i=e.value;r!==i&&(e.value=r),e.firstChild&&e.firstChild.nodeValue!==r&&(e.firstChild.nodeValue=r)}}function Se(t,e,n){let r=t.getAttribute(n),i=e.getAttribute(n);r!==i&&(r?e.setAttribute(n,r):e.removeAttribute(n))}function wt(t,e,n){let r=[],i=[],o=[],s=[],a=n.head.style,l=new Map;for(let u of t.children)l.set(u.outerHTML,u);for(let u of e.children){let d=l.has(u.outerHTML),y=n.head.shouldReAppend(u),m=n.head.shouldPreserve(u);d||m?y?i.push(u):(l.delete(u.outerHTML),o.push(u)):a===L.Append?y&&(i.push(u),s.push(u)):n.head.shouldRemove(u)!==!1&&i.push(u)}s.push(...l.values());let f=[];for(let u of s){let d=document.createRange().createContextualFragment(u.outerHTML).firstChild;if(!d)throw c("NewElementCouldNotBeCreated",{newNode:u});if(n.callbacks.beforeNodeAdded(d)){if(d.hasAttribute("href")||d.hasAttribute("src")){let y,m=new Promise(p=>{y=p});d.addEventListener("load",()=>{y(void 0)}),f.push(m)}e.appendChild(d),n.callbacks.afterNodeAdded(d),r.push(d)}}for(let u of i)n.callbacks.beforeNodeRemoved(u)!==!1&&(e.removeChild(u),n.callbacks.afterNodeRemoved(u));return n.head.afterHeadMorphed(e,{added:r,kept:o,removed:i}),f}function $(){}function Dn(t,e,n){return{target:t,newContent:e,config:n,morphStyle:n.morphStyle,ignoreActive:n.ignoreActive,idMap:$n(t,e),deadIds:new Set,callbacks:Object.assign({beforeNodeAdded:$,afterNodeAdded:$,beforeNodeMorphed:$,afterNodeMorphed:$,beforeNodeRemoved:$,afterNodeRemoved:$},n.callbacks),head:Object.assign({style:"merge",shouldPreserve:r=>r.getAttribute("im-preserve")==="true",shouldReAppend:r=>r.getAttribute("im-re-append")==="true",shouldRemove:$,afterHeadMorphed:$},n.head)}}function Pt(t,e,n){return!t||!e?!1:t.nodeType===e.nodeType&&t.tagName===e.tagName?t?.id?.length&&t.id===e.id?!0:ie(n,t,e)>0:!1}function _e(t,e){return!t||!e?!1:t.nodeType===e.nodeType&&t.tagName===e.tagName}function St(t,e,n){for(;t!==e;){let r=t;if(t=t?.nextSibling,!r)throw c("NoTemporaryNodeFound",{startInclusive:t,endExclusive:e});Mt(r,n)}return B(n,e),e.nextSibling}function In(t,e,n,r,i){let o=ie(i,n,e),s=null;if(o>0){s=r;let a=0;for(;s!=null;){if(Pt(n,s,i))return s;if(a+=ie(i,s,t),a>o)return null;s=s.nextSibling}}return s}function Ln(t,e,n,r){let i=n,o=e.nextSibling,s=0;for(;i&&o;){if(ie(r,i,t)>0)return null;if(_e(e,i))return i;if(_e(o,i)&&(s++,o=o.nextSibling,s>=2))return null;i=i.nextSibling}return i}var Tt=new DOMParser;function kn(t){let e=t.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let i=Tt.parseFromString(t,"text/html");if(e.match(/<\/html>/))return Te.add(i),i;let o=i.firstChild;return o?(Te.add(o),o):null}let r=Tt.parseFromString(``,"text/html").body.querySelector("template")?.content;if(!r)throw c("NoContentFound",{newContent:t});return Te.add(r),r}function Vn(t){if(t==null)return document.createElement("div");if(Te.has(t))return t;if(t instanceof Node){let n=document.createElement("div");return n.append(t),n}let e=document.createElement("div");for(let n of[...t])e.append(n);return e}function On(t,e,n){let r=[],i=[];for(;t;)r.push(t),t=t.previousSibling;for(;r.length>0;){let o=r.pop();i.push(o),e?.parentElement?.insertBefore(o,e)}for(i.push(e);n;)r.push(n),i.push(n),n=n.nextSibling;for(;r.length;)e?.parentElement?.insertBefore(r.pop(),e.nextSibling);return i}function Fn(t,e,n){let r=t.firstChild,i=r,o=0;for(;r;){let s=Hn(r,e,n);s>o&&(i=r,o=s),r=r.nextSibling}return i}function Hn(t,e,n){return _e(t,e)?.5+ie(n,t,e):0}function Mt(t,e){B(e,t),e.callbacks.beforeNodeRemoved(t)!==!1&&(t.remove(),e.callbacks.afterNodeRemoved(t))}function qn(t,e){return!t.deadIds.has(e)}function Wn(t,e,n){return t.idMap.get(n)?.has(e)||!1}function B(t,e){let n=t.idMap.get(e);if(n)for(let r of n)t.deadIds.add(r)}function ie(t,e,n){let r=t.idMap.get(e);if(!r)return 0;let i=0;for(let o of r)qn(t,o)&&Wn(t,o,n)&&++i;return i}function At(t,e){let n=t.parentElement,r=t.querySelectorAll("[id]");for(let i of r){let o=i;for(;o!==n&&o;){let s=e.get(o);s==null&&(s=new Set,e.set(o,s)),s.add(i.id),o=o.parentElement}}}function $n(t,e){let n=new Map;return At(t,n),At(e,n),n}var Ct={type:2,name:C.MergeFragments,onGlobalInit:async t=>{let e=document.createElement("template");O(C.MergeFragments,({fragments:n="
",selector:r="",mergeMode:i=je,settleDuration:o=`${ue}`,useViewTransition:s=`${ce}`})=>{let a=Number.parseInt(o),l=q(s);e.innerHTML=n.trim();let f=[...e.content.children];for(let u of f){if(!(u instanceof Element))throw c("NoFragmentsFound");let d=r||`#${u.getAttribute("id")}`,y=[...document.querySelectorAll(d)||[]];if(!y.length)throw c("NoTargetsFound",{selectorOrID:d});Y&&l?re.startViewTransition(()=>Nt(t,i,a,u,y)):Nt(t,i,a,u,y)}})}};function Nt(t,e,n,r,i){for(let o of i){o.classList.add(G);let s=o.outerHTML,a=o;switch(e){case L.Morph:{let u=_t(a,r,{callbacks:{beforeNodeRemoved:(d,y)=>(t.cleanup(d),!0)}});if(!u?.length)throw c("MorphFailed");a=u[0];break}case L.Inner:a.innerHTML=r.innerHTML;break;case L.Outer:a.replaceWith(r);break;case L.Prepend:a.prepend(r);break;case L.Append:a.append(r);break;case L.Before:a.before(r);break;case L.After:a.after(r);break;case L.UpsertAttributes:for(let u of r.getAttributeNames()){let d=r.getAttribute(u);a.setAttribute(u,d)}break;default:throw c("InvalidMergeMode",{mergeMode:e})}t.cleanup(a);let l=a.classList;l.add(G),t.apply(document.body),setTimeout(()=>{o.classList.remove(G),l.remove(G)},n);let f=a.outerHTML;s!==f&&(l.add(Ie),setTimeout(()=>{l.remove(Ie)},n))}}var Dt={type:2,name:C.MergeSignals,onGlobalInit:async t=>{O(C.MergeSignals,({signals:e="{}",onlyIfMissing:n=`${Ge}`})=>{let{signals:r}=t,i=q(n);r.merge(fe(e),i),t.apply(document.body)})}};var It={type:2,name:C.RemoveFragments,onGlobalInit:async()=>{O(C.RemoveFragments,({selector:t,settleDuration:e=`${ue}`,useViewTransition:n=`${ce}`})=>{if(!t.length)throw c("NoSelectorProvided");let r=Number.parseInt(e),i=q(n),o=document.querySelectorAll(t),s=()=>{for(let a of o)a.classList.add(G);setTimeout(()=>{for(let a of o)a.remove()},r)};Y&&i?re.startViewTransition(()=>s()):s()})}};var Lt={type:2,name:C.RemoveSignals,onGlobalInit:async t=>{O(C.RemoveSignals,({paths:e=""})=>{let n=e.split(` 10 | `).map(r=>r.trim());if(!n?.length)throw c("NoPathsProvided");t.signals.remove(...n),t.apply(document.body)})}};var kt={type:3,name:"clipboard",fn:(t,e)=>{if(!navigator.clipboard)throw c("ClipboardNotAvailable");navigator.clipboard.writeText(e)}};var Vt={type:1,name:"customValidity",keyReq:2,valReq:1,onLoad:({el:t,genRX:e,effect:n})=>{if(!(t instanceof HTMLInputElement))throw c("CustomValidityInvalidElement",{el:t});let r=e();return n(()=>{let i=r();if(typeof i!="string")throw c("CustomValidityInvalidExpression",{result:i});t.setCustomValidity(i)})}};var Ot="once",Ft="half",Ht="full",qt={type:1,name:"intersects",keyReq:2,mods:new Set([Ot,Ft,Ht]),onLoad:({el:t,rawKey:e,mods:n,genRX:r})=>{let i={threshold:0};n.has(Ht)?i.threshold=1:n.has(Ft)&&(i.threshold=.5);let o=r(),s=new IntersectionObserver(a=>{for(let l of a)l.isIntersecting&&(o(),n.has(Ot)&&(s.disconnect(),delete t.dataset[e]))},i);return s.observe(t),()=>s.disconnect()}};var Wt="session",$t={type:1,name:"persist",mods:new Set([Wt]),onLoad:({key:t,value:e,signals:n,effect:r,mods:i})=>{t===""&&(t=k);let o=i.has(Wt)?sessionStorage:localStorage,s=e.split(/\s+/).filter(f=>f!=="");s=s.map(f=>W(f));let a=()=>{let f=o.getItem(t)||"{}",u=JSON.parse(f);n.merge(u)},l=()=>{let f;s.length?f=n.subset(...s):f=n.values(),o.setItem(t,JSON.stringify(f))};return a(),r(()=>{l()})}};var Ut={type:1,name:"replaceUrl",keyReq:2,valReq:1,onLoad:({effect:t,genRX:e})=>{let n=e();return t(()=>{let r=n(),i=window.location.href,o=new URL(r,i).toString();window.history.replaceState({},"",o)})}};var xe="smooth",Le="instant",ke="auto",Gt="hstart",Bt="hcenter",jt="hend",Kt="hnearest",Jt="vstart",zt="vcenter",Xt="vend",Yt="vnearest",Un="focus",Re="center",Zt="start",Qt="end",en="nearest",tn={type:1,name:"scrollIntoView",keyReq:2,valReq:2,mods:new Set([xe,Le,ke,Gt,Bt,jt,Kt,Jt,zt,Xt,Yt,Un]),onLoad:({el:t,mods:e,rawKey:n})=>{t.tabIndex||t.setAttribute("tabindex","0");let r={behavior:xe,block:Re,inline:Re};if(e.has(xe)&&(r.behavior=xe),e.has(Le)&&(r.behavior=Le),e.has(ke)&&(r.behavior=ke),e.has(Gt)&&(r.inline=Zt),e.has(Bt)&&(r.inline=Re),e.has(jt)&&(r.inline=Qt),e.has(Kt)&&(r.inline=en),e.has(Jt)&&(r.block=Zt),e.has(zt)&&(r.block=Re),e.has(Xt)&&(r.block=Qt),e.has(Yt)&&(r.block=en),!(t instanceof HTMLElement||t instanceof SVGElement))throw c("NotHtmlSvgElement, el");return t.tabIndex||t.setAttribute("tabindex","0"),t.scrollIntoView(r),e.has("focus")&&t.focus(),delete t.dataset[n],()=>{}}};var nn="none",rn="display",on={type:1,name:"show",keyReq:2,valReq:1,onLoad:({el:{style:t},genRX:e,effect:n})=>{let r=e();return n(async()=>{r()?t.display===nn&&t.removeProperty(rn):t.setProperty(rn,nn)})}};var Ve="view-transition",sn={type:1,name:Ve,keyReq:2,valReq:1,onGlobalInit(){let t=!1;for(let e of document.head.childNodes)e instanceof HTMLMetaElement&&e.name===Ve&&(t=!0);if(!t){let e=document.createElement("meta");e.name=Ve,e.content="same-origin",document.head.appendChild(e)}},onLoad:({effect:t,el:e,genRX:n})=>{if(!Y){console.error("Browser does not support view transitions");return}let r=n();return t(()=>{let i=r();if(!i?.length)return;let o=e.style;o.viewTransitionName=i})}};var an={type:1,name:"attr",valReq:1,onLoad:({el:t,genRX:e,key:n,effect:r})=>{let i=e();return n===""?r(async()=>{let o=i();for(let[s,a]of Object.entries(o))t.setAttribute(s,a)}):(n=j(n),r(async()=>{let o=!1;try{o=i()}catch{}let s;typeof o=="string"?s=o:s=JSON.stringify(o),!s||s==="false"||s==="null"||s==="undefined"?t.removeAttribute(n):t.setAttribute(n,s)}))}};var Gn=/^data:(?[^;]+);base64,(?.*)$/,ln=["change","input","keydown"],un={type:1,name:"bind",keyReq:3,valReq:3,onLoad:t=>{let{el:e,value:n,key:r,signals:i,effect:o}=t,s=r||W(n),a=()=>{},l=()=>{};if(typeof s!="string")throw c("InvalidExpression");let f=e.tagName.toLowerCase(),u="",d=f.includes("input"),y=e.getAttribute("type"),m=f.includes("checkbox")||d&&y==="checkbox";m&&(u=!1),d&&y==="number"&&(u=0);let S=f.includes("select"),_=f.includes("radio")||d&&y==="radio",A=d&&y==="file";_&&(e.getAttribute("name")?.length||e.setAttribute("name",s)),i.upsertIfMissing(s,u),a=()=>{let T="value"in e,h=i.value(s),b=`${h}`;if(m||_){let w=e;m?w.checked=!!h||h==="true":_&&(w.checked=b===w.value)}else if(!A)if(S){let w=e;if(w.multiple)for(let P of w.options){if(P?.disabled)return;Array.isArray(h)||typeof h=="string"?P.selected=h.includes(P.value):typeof h=="number"?P.selected=h===Number(P.value):P.selected=h}else w.value=b}else T?e.value=b:e.setAttribute("value",b)},l=async()=>{if(A){let b=[...e?.files||[]],w=[],P=[],g=[];await Promise.all(b.map(N=>new Promise(R=>{let I=new FileReader;I.onload=()=>{if(typeof I.result!="string")throw c("InvalidFileResultType",{type:typeof I.result});let H=I.result.match(Gn);if(!H?.groups)throw c("InvalidDataUri",{result:I.result});w.push(H.groups.contents),P.push(H.groups.mime),g.push(N.name)},I.onloadend=()=>R(void 0),I.readAsDataURL(N)}))),i.setValue(s,w),i.setValue(`${s}Mimes`,P),i.setValue(`${s}Names`,g);return}let T=i.value(s),h=e||e;if(typeof T=="number"){let b=Number(h.value||h.getAttribute("value"));i.setValue(s,b)}else if(typeof T=="string"){let b=h.value||h.getAttribute("value")||"";i.setValue(s,b)}else if(typeof T=="boolean")if(m){let b=h.checked||h.getAttribute("checked")==="true";i.setValue(s,b)}else{let b=!!(h.value||h.getAttribute("value"));i.setValue(s,b)}else if(!(typeof T>"u"))if(Array.isArray(T))if(S){let P=[...e.selectedOptions].filter(g=>g.selected).map(g=>g.value);i.setValue(s,P)}else{let b=JSON.stringify(h.value.split(","));i.setValue(s,b)}else throw c("UnsupportedSignalType",{current:typeof T})};for(let T of ln)e.addEventListener(T,l);let D=o(()=>a());return()=>{D();for(let T of ln)e.removeEventListener(T,l)}}};var cn={type:1,name:"class",valReq:1,onLoad:({key:t,el:e,genRX:n,effect:r})=>{let i=e.classList,o=n();return r(()=>{if(t===""){let s=o();for(let[a,l]of Object.entries(s)){let f=a.split(/\s+/);l?i.add(...f):i.remove(...f)}}else{let s=o(),a=j(t);s?i.add(a):i.remove(a)}})}};function Oe(t){if(!t||t.size<=0)return 0;for(let e of t){if(e.endsWith("ms"))return Number(e.replace("ms",""));if(e.endsWith("s"))return Number(e.replace("s",""))*1e3;try{return Number.parseFloat(e)}catch{}}return 0}function oe(t,e,n=!1){return t?t.has(e.toLowerCase()):n}function fn(t,e,n=!1,r=!0){let i=-1,o=()=>i&&clearTimeout(i);return function(...a){o(),n&&!i&&t(...a),i=setTimeout(()=>{r&&t(...a),o()},e)}}function dn(t,e,n=!0,r=!1){let i=!1;return function(...s){i||(n&&t(...s),i=!0,setTimeout(()=>{i=!1,r&&t(...s)},e))}}var Fe=new Map,Bn="evt",pn={type:1,name:"on",keyReq:1,valReq:1,argNames:[Bn],onLoad:({el:t,key:e,genRX:n,mods:r,signals:i,effect:o})=>{let s=n(),a=t;r.has("window")&&(a=window);let l=m=>{m&&((r.has("prevent")||e==="submit")&&m.preventDefault(),r.has("stop")&&m.stopPropagation()),s(m)},f=r.get("debounce");if(f){let m=Oe(f),p=oe(f,"leading",!1),S=!oe(f,"notrail",!1);l=fn(l,m,p,S)}let u=r.get("throttle");if(u){let m=Oe(u),p=!oe(u,"noleading",!1),S=oe(u,"trail",!1);l=dn(l,m,p,S)}let d={capture:!0,passive:!1,once:!1};r.has("capture")||(d.capture=!1),r.has("passive")&&(d.passive=!0),r.has("once")&&(d.once=!0);let y=j(e).toLowerCase();switch(y){case"load":return l(),delete t.dataset.onLoad,()=>{};case"raf":{let m,p=()=>{l(),m=requestAnimationFrame(p)};return m=requestAnimationFrame(p),()=>{m&&cancelAnimationFrame(m)}}case"signals-change":return Ze(t,()=>{Fe.delete(t.id)}),o(()=>{let m=r.has("remote"),p=i.JSON(!1,m);(Fe.get(t.id)||"")!==p&&(Fe.set(t.id,p),l())});default:{if(r.has("outside")){a=document;let p=l;l=_=>{let A=_?.target;t.contains(A)||p(_)}}return a.addEventListener(y,l,d),()=>{a.removeEventListener(y,l)}}}}};var mn={type:1,name:"ref",keyReq:3,valReq:3,onLoad:({el:t,key:e,value:n,signals:r})=>{let i=e||W(n);return r.setValue(i,t),()=>r.setValue(i,null)}};var gn={type:1,name:"text",keyReq:2,valReq:1,onLoad:t=>{let{el:e,genRX:n,effect:r}=t,i=n();return e instanceof HTMLElement||c("NotHtmlElement"),r(()=>{let o=i(t);e.textContent=`${o}`})}};var{round:jn,max:Kn,min:Jn}=Math,hn={type:3,name:"fit",fn:(t,e,n,r,i,o,s=!1,a=!1)=>{let l=(e-n)/(r-n)*(o-i)+i;return a&&(l=jn(l)),s&&(l=Kn(i,Jn(o,l))),l}};var yn={type:3,name:"setAll",fn:({signals:t},e,n)=>{t.walk((r,i)=>{r.startsWith(e)&&(i.value=n)})}};var vn={type:3,name:"toggleAll",fn:({signals:t},e)=>{t.walk((n,r)=>{n.startsWith(e)&&(r.value=!r.value)})}};De.load(un,bt,mn,an,cn,pn,on,gn,gt,yt,vt,ht,mt,Ct,Dt,It,Lt,Et,kt,Vt,qt,$t,Ut,tn,sn,hn,yn,vn);var cs=De;export{cs as Datastar}; 11 | //# sourceMappingURL=datastar.js.map 12 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['**/*.md', '**/*.templ', '**/*.go'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; --------------------------------------------------------------------------------