├── .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 |
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 |
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(/