diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4300bc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+
+# Devenv
+.devenv*
+devenv.local.nix
+
+# direnv
+.direnv
+
+# pre-commit
+.pre-commit-config.yaml
+
diff --git a/cmd/apply/.todo b/cmd/apply/.todo
new file mode 100644
index 0000000..c8dd730
--- /dev/null
+++ b/cmd/apply/.todo
@@ -0,0 +1 @@
+ssh apply@hacklab.to CLI
\ No newline at end of file
diff --git a/cmd/memberizer/.todo b/cmd/memberizer/.todo
new file mode 100644
index 0000000..232d69f
--- /dev/null
+++ b/cmd/memberizer/.todo
@@ -0,0 +1 @@
+ssh memberizer@hacklab.to CLI
\ No newline at end of file
diff --git a/cmd/passwdweb/.todo b/cmd/passwdweb/.todo
new file mode 100644
index 0000000..57465c6
--- /dev/null
+++ b/cmd/passwdweb/.todo
@@ -0,0 +1 @@
+Web API just for reset password requests - so main web UI does not keep LDAP admin credentials
\ No newline at end of file
diff --git a/cmd/web/api/routes.go b/cmd/web/api/routes.go
new file mode 100644
index 0000000..1a841e9
--- /dev/null
+++ b/cmd/web/api/routes.go
@@ -0,0 +1,27 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// todo: service (ldap/db) auth
+
+func RpcRouter() chi.Router {
+ r := chi.NewRouter()
+
+ r.Get("/verify_card", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotImplemented)
+ })
+
+ return r
+}
+
+func RestRouter() chi.Router {
+ r := chi.NewRouter()
+
+ // db crud I guess
+
+ return r
+}
diff --git a/cmd/web/main.go b/cmd/web/main.go
new file mode 100644
index 0000000..727a64e
--- /dev/null
+++ b/cmd/web/main.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+ "log"
+ "members-platform/cmd/web/api"
+ "members-platform/cmd/web/ui"
+ "members-platform/internal/db"
+ "net/http"
+)
+
+func main() {
+ if err := db.Connect(true); err != nil {
+ log.Fatalln(err)
+ }
+ r := ui.Router()
+ r.Mount("/rpc", api.RpcRouter())
+ r.Mount("/rest", api.RestRouter())
+ log.Println("starting web server")
+ http.ListenAndServe(":18884", r)
+}
diff --git a/cmd/web/ui/htmx.go b/cmd/web/ui/htmx.go
new file mode 100644
index 0000000..be4ccd9
--- /dev/null
+++ b/cmd/web/ui/htmx.go
@@ -0,0 +1,22 @@
+package ui
+
+import (
+ "log"
+ "net/http"
+)
+
+// todo: integrate this with auth
+// todo: check HX-Current-URL and add title to component-only request
+func MaybeHtmxComponent(rw http.ResponseWriter, r *http.Request, page string, data any) {
+ if r.Header.Get("HX-Request") == "true" {
+ log.Println("got htmx request!")
+ if err := Page(rw, page, data); err != nil {
+ log.Println(err)
+ }
+ return
+ }
+
+ if err := PageWithShell(r.Context(), rw, page, data); err != nil {
+ log.Println(err)
+ }
+}
diff --git a/cmd/web/ui/pages/apply-success.html b/cmd/web/ui/pages/apply-success.html
new file mode 100644
index 0000000..962a3e1
--- /dev/null
+++ b/cmd/web/ui/pages/apply-success.html
@@ -0,0 +1,11 @@
+
+
+
You're good to go!
+
+ Thanks for applying to become a member. Operations and the membership have been notified and the process is underway.
+
+
+ We suggest you pay your dues now to prevent any hiccups during memberization:
+
+
Take me to the dues page
+
diff --git a/cmd/web/ui/pages/apply.html b/cmd/web/ui/pages/apply.html
new file mode 100644
index 0000000..3935277
--- /dev/null
+++ b/cmd/web/ui/pages/apply.html
@@ -0,0 +1,2 @@
+
+TODO: put form here
\ No newline at end of file
diff --git a/cmd/web/ui/pages/index.html b/cmd/web/ui/pages/index.html
new file mode 100644
index 0000000..4918853
--- /dev/null
+++ b/cmd/web/ui/pages/index.html
@@ -0,0 +1,9 @@
+{{ if IsMemberLoggedIn .Ctx.AuthLevel }}
+Hello, {{ .Ctx.CurrentUsername }}! We don't have much here yet.
+Do you want to look at the wiki ?
+{{ else }}
+Hello world
+more text should go here eventually
+
+If you are a current Hacklab member, you can log in here . Otherwise, why not become a member ?
+{{ end }}
\ No newline at end of file
diff --git a/cmd/web/ui/pages/login.html b/cmd/web/ui/pages/login.html
new file mode 100644
index 0000000..bcfab98
--- /dev/null
+++ b/cmd/web/ui/pages/login.html
@@ -0,0 +1,44 @@
+{{ if IsLoggedOut .Ctx.AuthLevel }}
+
+{{ else }}
+You are already logged in! Go Home?
+{{ end }}
diff --git a/cmd/web/ui/pages/passwd.html b/cmd/web/ui/pages/passwd.html
new file mode 100644
index 0000000..3a76e08
--- /dev/null
+++ b/cmd/web/ui/pages/passwd.html
@@ -0,0 +1,30 @@
+{{ if IsMemberLoggedIn .Ctx.AuthLevel }}
+todo: change password page
+{{ else }}
+
+{{ end }}
diff --git a/cmd/web/ui/routes.go b/cmd/web/ui/routes.go
new file mode 100644
index 0000000..2d9506d
--- /dev/null
+++ b/cmd/web/ui/routes.go
@@ -0,0 +1,123 @@
+package ui
+
+import (
+ "encoding/base64"
+ "log"
+ "members-platform/internal/auth"
+ "members-platform/static"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+)
+
+func Router() chi.Router {
+ r := chi.NewRouter()
+ r.Use(middleware.Logger)
+ r.Use(middleware.Recoverer)
+ r.Use(auth.AuthenticateHTTP)
+
+ registerStaticRoutes(r)
+ registerStaticPages(r)
+
+ // todo: this needs to be POST with CSRF
+ r.Get("/logout/", func(rw http.ResponseWriter, r *http.Request) {
+ http.SetCookie(rw, &http.Cookie{
+ Name: "HL-Session",
+ Value: "",
+ Path: "/",
+ Expires: time.Now().UTC(),
+ })
+ http.Redirect(rw, r, "/", http.StatusTemporaryRedirect)
+ })
+
+ r.Post("/login/", func(rw http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ log.Println(err)
+ }
+ username := r.Form.Get("username")
+ password := r.Form.Get("password")
+ ok, err := auth.AuthenticateUser(username, password)
+ if err != nil {
+ MaybeHtmxComponent(rw, r, "login", shit{Error: err.Error()})
+ return
+ }
+ if !ok {
+ MaybeHtmxComponent(rw, r, "login", shit{Error: "Invalid username or password"})
+ return
+ }
+
+ http.SetCookie(rw, &http.Cookie{
+ Name: "HL-Session",
+ Value: "Basic " + base64.StdEncoding.EncodeToString([]byte(strings.Join([]string{username, password}, ":"))),
+ HttpOnly: true,
+ Path: "/",
+ })
+
+ // htmx this is stupid
+ if r.Header.Get("HX-Request") == "true" {
+ rw.Header().Set("HX-Location", "/")
+ } else {
+ http.Redirect(rw, r, "/", http.StatusFound)
+ }
+ })
+
+ r.Post("/passwd/", func(rw http.ResponseWriter, r *http.Request) {
+ token := auth.CreateResetToken("lillian")
+ _ = auth.SendResetEmail("lillian@hacklab.to", "lillian", token)
+
+ // todo: don't, obviously
+ err := auth.DoChangePassword(
+ "uid=lilliantest,ou=people,dc=hacklab,dc=to",
+ "NotAPassword!!",
+ "uid=lilliantest,ou=people,dc=hacklab,dc=to",
+ "newpass1234",
+ )
+ data := shit{Error: "ok"}
+ if err != nil {
+ data.Error = err.Error()
+ }
+ MaybeHtmxComponent(rw, r, "passwd-reset", data)
+ })
+
+ return r
+}
+
+func registerStaticRoutes(r chi.Router) {
+ r.Get("/favicon.ico", func(rw http.ResponseWriter, r *http.Request) {
+ r.URL.Path = "/static/favicon.ico"
+ static.Server().ServeHTTP(rw, r)
+ })
+
+ r.Get("/robots.txt", func(rw http.ResponseWriter, _ *http.Request) {
+ rw.Write([]byte("User-Agent: *\nDisallow: *\nDisallow: /ban-me/admin.php"))
+ })
+
+ r.Get("/static/*", static.Server())
+}
+
+func registerStaticPages(r chi.Router) {
+ pathPages := map[string]string{
+ "/": "index",
+ "/login/": "login",
+ "/passwd/": "passwd",
+ "/apply/": "apply",
+ }
+
+ for k, v := range pathPages {
+ // ???
+ p := k
+ q := v
+ r.Get(p, func(rw http.ResponseWriter, r *http.Request) {
+ if err := PageWithShell(r.Context(), rw, q, nil); err != nil {
+ log.Println(err)
+ }
+ })
+ }
+}
+
+type shit struct {
+ Error string
+}
diff --git a/cmd/web/ui/shell.html b/cmd/web/ui/shell.html
new file mode 100644
index 0000000..b4c80e5
--- /dev/null
+++ b/cmd/web/ui/shell.html
@@ -0,0 +1,56 @@
+
+
+
+
+ {{ .Data.Title }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ if IsLoggedOut .Ctx.AuthLevel }}
+
+ Apply
+
+
+ Login
+
+ {{ end }}
+ {{ if IsApplicantLoggedIn .Ctx.AuthLevel }}
+
+ Apply
+
+ {{ end }}
+ {{ if IsMemberLoggedIn .Ctx.AuthLevel }}
+
+ Log Out
+
+ {{ end }}
+
+
+
+ Open main menu
+
+
+
+
+
+
+
+
+
+ {{ .Data.UnsafeInnerHTML }}
+
+
+
+
+ © Hacklab.to {{ .Data.CurrentYearForCopyright }} | Bug/problem? Email operations[at]hacklab.to
+ Source code
+
+
\ No newline at end of file
diff --git a/cmd/web/ui/templates.go b/cmd/web/ui/templates.go
new file mode 100644
index 0000000..d348372
--- /dev/null
+++ b/cmd/web/ui/templates.go
@@ -0,0 +1,98 @@
+package ui
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "members-platform/internal/auth"
+ "strconv"
+ "time"
+)
+
+//go:embed */*.html
+var tmplFS embed.FS
+
+//go:embed shell.html
+var shellTmpl string
+
+var pages *template.Template
+var shell *template.Template
+
+var pageTitles = map[string]string{
+ "login": "Log In",
+}
+
+var funcs template.FuncMap = map[string]any{
+ // auth
+ "IsLoggedOut": func(a auth.AuthLevel) bool {
+ return a == auth.AuthLevel_LoggedOut
+ },
+ "IsApplicantLoggedIn": func(a auth.AuthLevel) bool {
+ return a == auth.AuthLevel_Applicant
+ },
+ "IsMemberLoggedIn": func(a auth.AuthLevel) bool {
+ return a >= auth.AuthLevel_Member
+ },
+}
+
+func init() {
+ pages = template.Must(template.New("").Funcs(funcs).ParseFS(tmplFS, "pages/*.html"))
+ shell = template.Must(template.New("shell.html").Funcs(funcs).Parse(shellTmpl))
+}
+
+type TmplContext struct {
+ // TODO: csrf token
+ AuthLevel auth.AuthLevel
+ CurrentUsername string
+}
+
+type TmplData struct {
+ Ctx TmplContext
+ Data any
+}
+
+type ShellData struct {
+ Title string
+ CurrentYearForCopyright string
+
+ UnsafeInnerHTML template.HTML
+}
+
+func Page(w io.Writer, tmpl string, pageData any) error {
+ return pages.ExecuteTemplate(w, tmpl+".html", pageData)
+}
+
+func PageWithShell(ctx context.Context, w io.Writer, page string, pageData any) error {
+ pw := bytes.NewBuffer([]byte{})
+ data := TmplData{
+ Ctx: TmplContext{
+ AuthLevel: ctx.Value(auth.Ctx__AuthLevel).(auth.AuthLevel),
+ CurrentUsername: ctx.Value(auth.Ctx__AuthenticatedUser).(string),
+ },
+ Data: pageData,
+ }
+ if err := Page(pw, page, data); err != nil {
+ return fmt.Errorf("executing page template: %w", err)
+ }
+
+ title := ""
+ if v, ok := pageTitles[page]; ok {
+ title += v
+ title += " - "
+ }
+ title += "Hacklab Members Portal"
+
+ shellData := ShellData{
+ UnsafeInnerHTML: template.HTML(pw.Bytes()),
+ CurrentYearForCopyright: strconv.Itoa(time.Now().UTC().Year()),
+ Title: title,
+ }
+ data.Data = shellData
+ if err := shell.Execute(w, data); err != nil {
+ return fmt.Errorf("executing shell template: %w", err)
+ }
+ return nil
+}
diff --git a/copying.txt b/copying.txt
new file mode 100644
index 0000000..b3dbff0
--- /dev/null
+++ b/copying.txt
@@ -0,0 +1,22 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/devenv.lock b/devenv.lock
new file mode 100644
index 0000000..d76b8f1
--- /dev/null
+++ b/devenv.lock
@@ -0,0 +1,156 @@
+{
+ "nodes": {
+ "devenv": {
+ "locked": {
+ "dir": "src/modules",
+ "lastModified": 1702239828,
+ "narHash": "sha256-H+z5LY1XslLLIlsh0pirHmveD7Eh6QQUT96VNSRJW9w=",
+ "owner": "cachix",
+ "repo": "devenv",
+ "rev": "895e8403410c3ec14d1e8cae94e88b4e7e2e8c2f",
+ "type": "github"
+ },
+ "original": {
+ "dir": "src/modules",
+ "owner": "cachix",
+ "repo": "devenv",
+ "type": "github"
+ }
+ },
+ "flake-compat": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1673956053,
+ "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "type": "github"
+ }
+ },
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1685518550,
+ "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "gitignore": {
+ "inputs": {
+ "nixpkgs": [
+ "pre-commit-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1660459072,
+ "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1702272962,
+ "narHash": "sha256-D+zHwkwPc6oYQ4G3A1HuadopqRwUY/JkMwHz1YF7j4Q=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "e97b3e4186bcadf0ef1b6be22b8558eab1cdeb5d",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-stable": {
+ "locked": {
+ "lastModified": 1685801374,
+ "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "c37ca420157f4abc31e26f436c1145f8951ff373",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-23.05",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "pre-commit-hooks": {
+ "inputs": {
+ "flake-compat": "flake-compat",
+ "flake-utils": "flake-utils",
+ "gitignore": "gitignore",
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "nixpkgs-stable": "nixpkgs-stable"
+ },
+ "locked": {
+ "lastModified": 1702325376,
+ "narHash": "sha256-biLGx2LzU2+/qPwq+kWwVBgXs3MVYT1gPa0fCwpLplU=",
+ "owner": "cachix",
+ "repo": "pre-commit-hooks.nix",
+ "rev": "e1d203c2fa7e2593c777e490213958ef81f71977",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "pre-commit-hooks.nix",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "devenv": "devenv",
+ "nixpkgs": "nixpkgs",
+ "pre-commit-hooks": "pre-commit-hooks"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/devenv.nix b/devenv.nix
new file mode 100644
index 0000000..cf5d7ef
--- /dev/null
+++ b/devenv.nix
@@ -0,0 +1,21 @@
+{ pkgs, ... }:
+
+{
+ packages = [
+ pkgs.nodejs_20
+ ];
+
+ languages.go.enable = true;
+
+ scripts.go-gen.exec = "go generate ./...";
+ scripts.go-for-watch.exec = "go-gen && go $@";
+ scripts.watch-web.exec = ''
+ go run github.com/mitranim/gow@latest \
+ -v -i .devenv -i internal/db/queries -i static/tailwind.css \
+ -g go-for-watch -e go,html,css,txt run ./cmd/web
+ '';
+
+ env.DATABASE_URL = "postgresql://postgres:postgres@127.0.0.1:5432/membersdb?sslmode=disable";
+ env.LDAP_URL = "127.0.0.1:6636";
+ env.PASSWD_RESET_HASHER_SECRET = "devsecret";
+}
diff --git a/devenv.yaml b/devenv.yaml
new file mode 100644
index 0000000..c7cb5ce
--- /dev/null
+++ b/devenv.yaml
@@ -0,0 +1,3 @@
+inputs:
+ nixpkgs:
+ url: github:NixOS/nixpkgs/nixpkgs-unstable
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..cdd6cf6
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,17 @@
+module members-platform
+
+go 1.21.4
+
+require (
+ github.com/go-chi/chi/v5 v5.0.10
+ github.com/go-ldap/ldap v3.0.3+incompatible
+ github.com/lib/pq v1.10.9
+ github.com/golang-migrate/migrate/v4 v4.16.2
+)
+
+require (
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ go.uber.org/atomic v1.7.0 // indirect
+ gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..8f15fc1
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,24 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
+github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk=
+github.com/go-ldap/ldap v3.0.3+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
+github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA=
+github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk=
+github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
+github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
diff --git a/internal/auth/auth_level.go b/internal/auth/auth_level.go
new file mode 100644
index 0000000..db777f9
--- /dev/null
+++ b/internal/auth/auth_level.go
@@ -0,0 +1,11 @@
+package auth
+
+type AuthLevel int
+
+const (
+ AuthLevel_LoggedOut AuthLevel = iota
+ AuthLevel_Applicant
+ AuthLevel_Member
+ AuthLevel_Board
+ AuthLevel_Operations
+)
diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go
new file mode 100644
index 0000000..92f6479
--- /dev/null
+++ b/internal/auth/ldap.go
@@ -0,0 +1,61 @@
+package auth
+
+import (
+ "crypto/tls"
+ "fmt"
+ "os"
+
+ "github.com/go-ldap/ldap"
+)
+
+func AuthenticateUser(username, password string) (bool, error) {
+ ldapURL := os.Getenv("LDAP_URL")
+ if ldapURL == "" {
+ return false, fmt.Errorf("missing LDAP_URL in environment")
+ }
+ conn, err := ldap.DialTLS("tcp", ldapURL, &tls.Config{
+ // todo(infra): don't
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ return false, fmt.Errorf("dial ldap: %w", err)
+ }
+ defer conn.Close()
+
+ if err := conn.Bind(fmt.Sprintf("uid=%s,ou=people,dc=hacklab,dc=to", username), password); err != nil {
+ if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
+ return false, nil
+ }
+ return false, fmt.Errorf("bind ldap: %w", err)
+ }
+
+ return true, nil
+}
+
+// todo: authenticate service
+
+// DoChangePassword can be used for both password reset and password change
+// if password reset, bind with admin user
+// otherwise you can bind with your current credentials to change your account password
+func DoChangePassword(bindDN, bindPassword, targetDN, newPassword string) error {
+ ldapURL := os.Getenv("LDAP_URL")
+ if ldapURL == "" {
+ return fmt.Errorf("missing LDAP_URL in environment")
+ }
+ conn, err := ldap.DialTLS("tcp", ldapURL, &tls.Config{
+ // todo(infra): don't
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ return fmt.Errorf("dial ldap: %w", err)
+ }
+ defer conn.Close()
+
+ // bind as admin user
+ if err := conn.Bind(bindDN, bindPassword); err != nil {
+ return fmt.Errorf("bind ldap: %w", err)
+ }
+
+ _, err = conn.PasswordModify(ldap.NewPasswordModifyRequest(targetDN, bindPassword, newPassword))
+ return err
+}
diff --git a/internal/auth/resetpassword.go b/internal/auth/resetpassword.go
new file mode 100644
index 0000000..40f3b22
--- /dev/null
+++ b/internal/auth/resetpassword.go
@@ -0,0 +1,82 @@
+package auth
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "members-platform/internal/mailer"
+ "net/smtp"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// poor hacker's jwt
+func CreateResetToken(username string) string {
+ signkey := os.Getenv("PASSWD_RESET_HASHER_SECRET")
+ if signkey == "" {
+ panic(fmt.Errorf("missing PASSWD_RESET_HASHER_SECRET in environment"))
+ }
+
+ hmacer := hmac.New(sha256.New, []byte(signkey))
+
+ var b strings.Builder
+
+ b.WriteString(base64.RawURLEncoding.EncodeToString([]byte(username)))
+ b.WriteString(".")
+ b.WriteString(base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(int(time.Now().UTC().Unix())))))
+
+ hmacer.Write([]byte(b.String()))
+ b.WriteString(".")
+ b.WriteString(base64.RawURLEncoding.EncodeToString(hmacer.Sum(nil)))
+
+ return b.String()
+}
+
+// todo: validate reset token
+
+func SendResetEmail(email, username, token string) error {
+ d := mailer.ResetPasswordData{
+ ToAddress: email,
+ Username: username,
+ Token: token,
+ }
+
+ text, err := mailer.ExecuteTemplate("reset-password", d)
+ if err != nil {
+ return fmt.Errorf("build email body: %w", err)
+ }
+
+ smtpServer := os.Getenv("SMTP_SERVER")
+ if smtpServer == "" {
+ return fmt.Errorf("missing SMTP_SERVER in environment")
+ }
+
+ conn, err := smtp.Dial(smtpServer)
+ if err != nil {
+ return fmt.Errorf("dial smtp: %w", err)
+ }
+ defer conn.Close()
+
+ if err := conn.Mail("operations+automated@hacklab.to"); err != nil {
+ return fmt.Errorf("conn.Mail: %w", err)
+ }
+
+ if err := conn.Rcpt(email); err != nil {
+ return fmt.Errorf("conn.Rcpt: %w", err)
+ }
+
+ wc, err := conn.Data()
+ if err != nil {
+ return fmt.Errorf("conn.Data: %w", err)
+ }
+ defer wc.Close()
+
+ if _, err := wc.Write([]byte(text)); err != nil {
+ return fmt.Errorf("write email body: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/auth/web.go b/internal/auth/web.go
new file mode 100644
index 0000000..de77d75
--- /dev/null
+++ b/internal/auth/web.go
@@ -0,0 +1,77 @@
+package auth
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+)
+
+type _HLContextKey int
+
+const (
+ Ctx__AuthenticatedUser _HLContextKey = iota
+ Ctx__AuthLevel
+)
+
+func AuthenticateHTTP(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ // http basic auth, but in a custom header so we can logout via UI
+ // sorry for the func() nonsense, I'm a rustacean
+ creds := func() string {
+ if creds := r.Header.Get("HL-Session"); creds != "" {
+ return creds
+ } else if cookie, _ := r.Cookie("HL-Session"); cookie != nil {
+ return cookie.Value
+ }
+ return ""
+ }()
+
+ username, password := func() (string, string) {
+ if creds != "" {
+ b64 := strings.TrimPrefix(creds, "Basic ")
+ if creds == b64 {
+ // unknown auth scheme
+ return "", ""
+ }
+ b, err := base64.StdEncoding.DecodeString(b64)
+ if err != nil {
+ log.Println(fmt.Errorf("error decoding credentials: %w", err))
+ return "", ""
+ }
+ split := strings.Split(string(b), ":")
+ if len(split) != 2 {
+ log.Println("invalid count of auth parts")
+ }
+ return split[0], split[1]
+ }
+ // no auth provided
+ return "", ""
+ }()
+
+ if username == "" {
+ // unauthenticated request or malformed credentials
+ r = r.WithContext(context.WithValue(r.Context(), Ctx__AuthenticatedUser, ""))
+ r = r.WithContext(context.WithValue(r.Context(), Ctx__AuthLevel, AuthLevel_LoggedOut))
+ next.ServeHTTP(rw, r)
+ return
+ }
+
+ ok, err := AuthenticateUser(username, password)
+ if err != nil {
+ panic(err)
+ }
+
+ if ok {
+ r = r.WithContext(context.WithValue(r.Context(), Ctx__AuthenticatedUser, username))
+ // todo: get >member auth level from db
+ r = r.WithContext(context.WithValue(r.Context(), Ctx__AuthLevel, AuthLevel_Member))
+ next.ServeHTTP(rw, r)
+ } else {
+ rw.WriteHeader(http.StatusUnauthorized)
+ // idk, log out, something
+ }
+ })
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..11baa09
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,56 @@
+package db
+
+import (
+ "database/sql"
+ "embed"
+ "fmt"
+ "members-platform/internal/db/queries"
+ "os"
+
+ "github.com/golang-migrate/migrate/v4"
+ "github.com/golang-migrate/migrate/v4/database/postgres"
+ "github.com/golang-migrate/migrate/v4/source/iofs"
+ _ "github.com/lib/pq"
+)
+
+//go:embed migrate/*.sql
+var migrations embed.FS
+
+//go:generate go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.24.0 generate
+
+var DB *queries.Queries
+
+func Connect(doMigrate bool) error {
+ url := os.Getenv("DATABASE_URL")
+ if url == "" {
+ return fmt.Errorf("missing DATABASE_URL in environment")
+ }
+ pg, err := sql.Open("postgres", url)
+ if err != nil {
+ return err
+ }
+ if doMigrate {
+ source, err := iofs.New(migrations, "migrate")
+ if err != nil {
+ return fmt.Errorf("set up migration source: %s", err)
+ }
+ database, err := postgres.WithInstance(pg, &postgres.Config{})
+ if err != nil {
+ return fmt.Errorf("set up migration db connection: %w", err)
+ }
+ m, err := migrate.NewWithInstance(
+ "file://migrate",
+ source,
+ url,
+ database,
+ )
+ if err != nil {
+ return fmt.Errorf("set up migration instance: %w", err)
+ }
+ if err := m.Up(); err != nil && err != migrate.ErrNoChange {
+ return fmt.Errorf("run migrations: %w", err)
+ }
+ }
+ DB = queries.New(pg)
+ return nil
+}
diff --git a/internal/db/migrate/0_init.up.sql b/internal/db/migrate/0_init.up.sql
new file mode 100644
index 0000000..e904809
--- /dev/null
+++ b/internal/db/migrate/0_init.up.sql
@@ -0,0 +1,40 @@
+-- todo: proper schema idk
+
+create table applicants (
+ id serial primary key,
+ preferred_name text not null,
+ preferred_pronouns text,
+ nickname text not null,
+ username text unique not null,
+ contact_email text unique not null,
+ list_email text,
+ application_reason text not null,
+ sponsor1 text not null,
+ sponsor2 text not null,
+ picture_url text unique not null,
+ heard_from text,
+ links text,
+ token_type int not null,
+ application_state int not null default 1
+);
+
+create table members (
+ id serial primary key,
+ preferred_name text not null,
+ preferred_pronouns text not null,
+ username text unique not null,
+ contact_email text unique not null,
+ list_email text,
+ picture_url text unique not null,
+ bio_freeform text,
+ user_groups text[] not null default array[]::text[],
+ is_current_member bool not null default true,
+
+ -- board-only fields
+ application_id int not null references applicants(id),
+ legal_name text,
+ waiver_sign_date timestamp,
+ access_card_id text,
+ emergency_contact text,
+ helcim_subscription_id text
+);
diff --git a/internal/db/queries.sql b/internal/db/queries.sql
new file mode 100644
index 0000000..9efe7ed
--- /dev/null
+++ b/internal/db/queries.sql
@@ -0,0 +1,35 @@
+-- name: GetMemberSanitized :one
+select
+ id,
+ preferred_name,
+ preferred_pronouns,
+ username,
+ contact_email,
+ list_email,
+ picture_url,
+ bio_freeform,
+ user_groups,
+ is_current_member
+from members
+where id = $1;
+
+-- name: GetMemberFull :one
+select
+ id,
+ preferred_name,
+ preferred_pronouns,
+ username,
+ contact_email,
+ list_email,
+ picture_url,
+ bio_freeform,
+ user_groups,
+ is_current_member,
+ application_id,
+ legal_name,
+ waiver_sign_date,
+ access_card_id,
+ emergency_contact,
+ helcim_subscription_id
+from members
+where id = $1;
diff --git a/internal/db/queries/db.go b/internal/db/queries/db.go
new file mode 100644
index 0000000..d4cdb25
--- /dev/null
+++ b/internal/db/queries/db.go
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.24.0
+
+package queries
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+ PrepareContext(context.Context, string) (*sql.Stmt, error)
+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
diff --git a/internal/db/queries/models.go b/internal/db/queries/models.go
new file mode 100644
index 0000000..9207f7d
--- /dev/null
+++ b/internal/db/queries/models.go
@@ -0,0 +1,46 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.24.0
+
+package queries
+
+import (
+ "database/sql"
+)
+
+type Applicant struct {
+ ID int32
+ PreferredName string
+ PreferredPronouns sql.NullString
+ Nickname string
+ Username string
+ ContactEmail string
+ ListEmail sql.NullString
+ ApplicationReason string
+ Sponsor1 string
+ Sponsor2 string
+ PictureUrl string
+ HeardFrom sql.NullString
+ Links sql.NullString
+ TokenType int32
+ ApplicationState int32
+}
+
+type Member struct {
+ ID int32
+ PreferredName string
+ PreferredPronouns string
+ Username string
+ ContactEmail string
+ ListEmail sql.NullString
+ PictureUrl string
+ BioFreeform sql.NullString
+ UserGroups []string
+ IsCurrentMember bool
+ ApplicationID int32
+ LegalName sql.NullString
+ WaiverSignDate sql.NullTime
+ AccessCardID sql.NullString
+ EmergencyContact sql.NullString
+ HelcimSubscriptionID sql.NullString
+}
diff --git a/internal/db/queries/queries.sql.go b/internal/db/queries/queries.sql.go
new file mode 100644
index 0000000..8c15a82
--- /dev/null
+++ b/internal/db/queries/queries.sql.go
@@ -0,0 +1,106 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.24.0
+// source: queries.sql
+
+package queries
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/lib/pq"
+)
+
+const getMemberFull = `-- name: GetMemberFull :one
+select
+ id,
+ preferred_name,
+ preferred_pronouns,
+ username,
+ contact_email,
+ list_email,
+ picture_url,
+ bio_freeform,
+ user_groups,
+ is_current_member,
+ application_id,
+ legal_name,
+ waiver_sign_date,
+ access_card_id,
+ emergency_contact,
+ helcim_subscription_id
+from members
+where id = $1
+`
+
+func (q *Queries) GetMemberFull(ctx context.Context, id int32) (Member, error) {
+ row := q.db.QueryRowContext(ctx, getMemberFull, id)
+ var i Member
+ err := row.Scan(
+ &i.ID,
+ &i.PreferredName,
+ &i.PreferredPronouns,
+ &i.Username,
+ &i.ContactEmail,
+ &i.ListEmail,
+ &i.PictureUrl,
+ &i.BioFreeform,
+ pq.Array(&i.UserGroups),
+ &i.IsCurrentMember,
+ &i.ApplicationID,
+ &i.LegalName,
+ &i.WaiverSignDate,
+ &i.AccessCardID,
+ &i.EmergencyContact,
+ &i.HelcimSubscriptionID,
+ )
+ return i, err
+}
+
+const getMemberSanitized = `-- name: GetMemberSanitized :one
+select
+ id,
+ preferred_name,
+ preferred_pronouns,
+ username,
+ contact_email,
+ list_email,
+ picture_url,
+ bio_freeform,
+ user_groups,
+ is_current_member
+from members
+where id = $1
+`
+
+type GetMemberSanitizedRow struct {
+ ID int32
+ PreferredName string
+ PreferredPronouns string
+ Username string
+ ContactEmail string
+ ListEmail sql.NullString
+ PictureUrl string
+ BioFreeform sql.NullString
+ UserGroups []string
+ IsCurrentMember bool
+}
+
+func (q *Queries) GetMemberSanitized(ctx context.Context, id int32) (GetMemberSanitizedRow, error) {
+ row := q.db.QueryRowContext(ctx, getMemberSanitized, id)
+ var i GetMemberSanitizedRow
+ err := row.Scan(
+ &i.ID,
+ &i.PreferredName,
+ &i.PreferredPronouns,
+ &i.Username,
+ &i.ContactEmail,
+ &i.ListEmail,
+ &i.PictureUrl,
+ &i.BioFreeform,
+ pq.Array(&i.UserGroups),
+ &i.IsCurrentMember,
+ )
+ return i, err
+}
diff --git a/internal/db/sqlc.yaml b/internal/db/sqlc.yaml
new file mode 100644
index 0000000..170c991
--- /dev/null
+++ b/internal/db/sqlc.yaml
@@ -0,0 +1,9 @@
+version: 2
+sql:
+ - engine: "postgresql"
+ schema: "migrate/*.sql"
+ queries: "queries.sql"
+ gen:
+ go:
+ package: "queries"
+ out: "queries"
\ No newline at end of file
diff --git a/internal/mailer/templates.go b/internal/mailer/templates.go
new file mode 100644
index 0000000..e498ddb
--- /dev/null
+++ b/internal/mailer/templates.go
@@ -0,0 +1,32 @@
+package mailer
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "text/template"
+)
+
+//go:embed templates/*
+var tmplFS embed.FS
+
+var emails *template.Template
+
+const footer = "\n--------------\n\nThis was an automated email sent by members.hacklab.to"
+
+func init() {
+ emails = template.Must(template.ParseFS(tmplFS, "templates/*.txt"))
+}
+
+func ExecuteTemplate(tmpl string, data any) (string, error) {
+ w := bytes.NewBuffer([]byte{})
+
+ if err := emails.ExecuteTemplate(w, tmpl+".txt", data); err != nil {
+ return "", fmt.Errorf("execute email template: %w", err)
+ }
+ if _, err := w.Write([]byte(footer)); err != nil {
+ return "", fmt.Errorf("write email footer: %w", err)
+ }
+
+ return w.String(), nil
+}
diff --git a/internal/mailer/templates/new-member.txt b/internal/mailer/templates/new-member.txt
new file mode 100644
index 0000000..773c187
--- /dev/null
+++ b/internal/mailer/templates/new-member.txt
@@ -0,0 +1,26 @@
+todo: fix
+Prospective hacklab member {{ .PreferredName }} ({{ .Nickname }}) has applied!
+
+Full application details below:
+
+-----------------------
+
+Name: {{ .PreferredName }} ({{ .Nickname }})
+Username: {{ .Username }}
+Email: {{ .ContactEmail }}
+
+Profile: {{ .PictureUrl }}
+
+Sponsors: {{ .Sponsor1 }}, {{ .Sponsor2 }}
+
+Links:
+{{ if .Links }}{{ .Links }}{{ else }}None provided.{{ end }}
+
+Bio / Why they want to join:
+{{ .ApplicationReason }}
+
+How'd they hear about us:
+{{ if .HeardFrom }}{{ .HeardFrom }}{{ else }}None provided.{{ end }}
+
+Preferred gender pronoun:
+{{ .PreferredPronouns }}
diff --git a/internal/mailer/templates/reset-password.txt b/internal/mailer/templates/reset-password.txt
new file mode 100644
index 0000000..93dc2e7
--- /dev/null
+++ b/internal/mailer/templates/reset-password.txt
@@ -0,0 +1,13 @@
+From: members.hacklab.to
+Reply-To: operations@hacklab.to
+To: {{ .ToAddress }}
+Subject: Hacklab.to password reset
+
+Hello {{ .Username }},
+
+Click here to reset your password:
+https://members.hacklab.to/passwd/?token={{ .Token }}
+
+This link is valid for 24 hours.
+
+If you didn't request a password reset, please ignore this email.
diff --git a/internal/mailer/types.go b/internal/mailer/types.go
new file mode 100644
index 0000000..04c5612
--- /dev/null
+++ b/internal/mailer/types.go
@@ -0,0 +1,7 @@
+package mailer
+
+type ResetPasswordData struct {
+ ToAddress string
+ Username string
+ Token string
+}
diff --git a/readme.txt b/readme.txt
new file mode 100644
index 0000000..6f29b12
--- /dev/null
+++ b/readme.txt
@@ -0,0 +1,12 @@
+-- members-platform --
+
+A very WIP new site platform for members.hacklab.to. Most things to be
+implemented. Feel free to jump in and fix one of the `todo`s!
+
+Built on:
+- PostgreSQL, with sqlc and golang-migrate
+- LDAP for authentication
+- HTMX for web reactivity
+- Tailwind CSS
+- (in future) Charm Bracelet for TUIs
+- A whole lot of the Go standard library :)
diff --git a/static/HackStencil_horizontal.svg b/static/HackStencil_horizontal.svg
new file mode 100644
index 0000000..be79273
--- /dev/null
+++ b/static/HackStencil_horizontal.svg
@@ -0,0 +1,168 @@
+
+
+
+
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..b4102d1
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/static/htmx.min.js b/static/htmx.min.js
new file mode 100644
index 0000000..6c0a0f2
--- /dev/null
+++ b/static/htmx.min.js
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:t,process:Bt,on:Z,off:K,trigger:ce,ajax:Or,find:C,findAll:f,closest:v,values:function(e,t){var r=ur(e,t||"post");return r.values},remove:B,addClass:F,removeClass:n,toggleClass:V,takeClass:j,defineExtension:kr,removeExtension:Pr,logAll:X,logNone:U,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true},parseInterval:d,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.9"};var r={addTriggerHandler:Tt,bodyContains:se,canAccessLocalStorage:M,findThisElement:de,filterValues:dr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Cr,getHeaders:vr,getInputValues:ur,getInternalData:ae,getSwapSpecification:mr,getTriggerSpecs:Qe,getTarget:ge,makeFragment:l,mergeObjects:le,makeSettleInfo:R,oobSwap:xe,querySelectorExt:ue,selectAndSwap:Ue,settleImmediately:Yt,shouldCancel:it,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:T};var b=["get","post","put","delete","patch"];var w=b.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function S(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=S(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function q(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function H(e){return e.match(/"+e+" ",0);return r.querySelector("template").content}else{var n=q(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i("",1);case"col":return i("",2);case"tr":return i("",2);case"td":case"th":return i("",3);case"script":case"style":return i(""+e+"
",1);default:return i(e,0)}}}function ie(e){if(e){e()}}function L(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function A(e){return L(e,"Function")}function N(e){return L(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function I(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function P(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){x(e);return null}}function M(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function D(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!t.match("^/$")){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return wr(re().body,function(){return eval(e)})}function t(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function X(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function U(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function B(e,t){e=s(e);if(t){setTimeout(function(){B(e);e=null},t)}else{e.parentElement.removeChild(e)}}function F(e,t,r){e=s(e);if(r){setTimeout(function(){F(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=s(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function V(e,t){e=s(e);e.classList.toggle(t)}function j(e,t){e=s(e);oe(e.parentElement.children,function(e){n(e,t)});F(e,t)}function v(e,t){e=s(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function _(e,t){return e.substring(e.length-t.length)===t}function z(e){var t=e.trim();if(g(t,"<")&&_(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function W(e,t){if(t.indexOf("closest ")===0){return[v(e,z(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,z(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[$(e,z(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[G(e,z(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(z(t))}}var $=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return W(e,t)[0]}else{return W(re().body,e)[0]}}function s(e){if(L(e,"String")){return C(e)}else{return e}}function J(e,t,r){if(A(t)){return{target:re().body,event:e,listener:t}}else{return{target:s(e),event:t,listener:r}}}function Z(t,r,n){Dr(function(){var e=J(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=A(r);return e?r:n}function K(t,r,n){Dr(function(){var e=J(t,r,n);e.target.removeEventListener(e.event,e.listener)});return A(r)?r:n}var ve=re().createElement("output");function Y(e,t){var r=ne(e,t);if(r){if(r==="this"){return[de(e,t)]}else{var n=W(e,r);if(n.length===0){x('The selector "'+r+'" on '+t+" returned no matches!");return[ve]}else{return n}}}}function de(e,t){return c(e,function(e){return te(e,t)!=null})}function ge(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return de(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function me(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!ye(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){De(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function be(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();pe(e,i);s.tasks.push(function(){pe(e,a)})}}})}function Ee(e){return function(){n(e,Q.config.addedClass);Bt(e);Ot(e);Ce(e);ce(e,"htmx:load")}}function Ce(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Se(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;F(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Ee(i))}}}function Te(e,t){var r=0;while(r-1){var t=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var r=t.match(/]*>|>)([\s\S]*?)<\/title>/im);if(r){return r[2]}}}function Ue(e,t,r,n,i,a){i.title=Xe(n);var o=l(n);if(o){be(r,o,i);o=Me(r,o,a);we(o);return De(e,r,t,o,i)}}function Be(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!N(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=wr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Je(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!e[0].match(t)){r+=e.shift()}return r}function Ke(e){var t;if(e.length>0&&We.test(e[0])){e.shift();t=y(e,$e).trim();e.shift()}else{t=y(e,p)}return t}var Ye="input, textarea, select";function Qe(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Ge(t);do{y(n,ze);var i=n.length;var a=y(n,/[,\[\s]/);if(a!==""){if(a==="every"){var o={trigger:"every"};y(n,ze);o.pollInterval=d(y(n,/[,\[\s]/));y(n,ze);var s=Ze(e,n,"event");if(s){o.eventFilter=s}r.push(o)}else if(a.indexOf("sse:")===0){r.push({trigger:"sse",sseEvent:a.substr(4)})}else{var l={trigger:a};var s=Ze(e,n,"event");if(s){l.eventFilter=s}while(n.length>0&&n[0]!==","){y(n,ze);var u=n.shift();if(u==="changed"){l.changed=true}else if(u==="once"){l.once=true}else if(u==="consume"){l.consume=true}else if(u==="delay"&&n[0]===":"){n.shift();l.delay=d(y(n,p))}else if(u==="from"&&n[0]===":"){n.shift();if(We.test(n[0])){var f=Ke(n)}else{var f=y(n,p);if(f==="closest"||f==="find"||f==="next"||f==="previous"){n.shift();var c=Ke(n);if(c.length>0){f+=" "+c}}}l.from=f}else if(u==="target"&&n[0]===":"){n.shift();l.target=Ke(n)}else if(u==="throttle"&&n[0]===":"){n.shift();l.throttle=d(y(n,p))}else if(u==="queue"&&n[0]===":"){n.shift();l.queue=y(n,p)}else if(u==="root"&&n[0]===":"){n.shift();l[u]=Ke(n)}else if(u==="threshold"&&n[0]===":"){n.shift();l[u]=y(n,p)}else{fe(e,"htmx:syntax:error",{token:n.shift()})}}r.push(l)}}if(n.length===i){fe(e,"htmx:syntax:error",{token:n.shift()})}y(n,ze)}while(n[0]===","&&n.shift())}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,Ye)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function et(e){ae(e).cancelled=true}function tt(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ot(r,e,Vt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}tt(e,t,r)}},r.pollInterval)}function rt(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function nt(t,r,e){if(t.tagName==="A"&&rt(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){st(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function it(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function at(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ot(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function st(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=W(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(at(a,e)){return}if(l||it(e,a)){e.preventDefault()}if(ot(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var lt=false;var ut=null;function ft(){if(!ut){ut=function(){lt=true};window.addEventListener("scroll",ut);setInterval(function(){if(lt){lt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){ct(e)})}},200)}}function ct(t){if(!o(t,"data-hx-revealed")&&k(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function ht(e,t,r){var n=P(r);for(var i=0;i=0){var t=mt(n);setTimeout(function(){vt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(dt(s)){return}var t=e.data;T(s,function(e){t=e.transformResponse(t,null,s)});var r=R(s);var n=l(t);var i=I(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(it(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function mt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}x('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function pt(e,t,r){var n=P(r);for(var i=0;i0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Pt(o)}for(var l in r){Mt(e,l,r[l])}}}function Xt(t){Oe(t);for(var e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function $t(e){if(!M()){return null}e=D(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=zt();var r=R(t);var n=Xe(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Pe(t,e,r);Yt(r.tasks);_t=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function er(e){Jt();e=e||location.pathname+location.search;var t=$t(e);if(t){var r=l(t.content);var n=zt();var i=R(n);Pe(n,r,i);Yt(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);_t=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Qt(e)}}}function tr(e){var t=Y(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function rr(e){var t=Y(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function nr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ir(e,t){for(var r=0;r=0}function mr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!gr(e)){n["show"]="top"}if(r){var i=P(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{x("Unknown modifier in hx-swap: "+o)}}}}return n}function pr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yr(t,r,n){var i=null;T(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(pr(r)){return hr(n)}else{return cr(n)}}}function R(e){return{tasks:[],elts:[e]}}function xr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function br(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=wr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return br(u(e),t,r,n)}function wr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Sr(e,t){return br(e,"hx-vars",true,t)}function Er(e,t){return br(e,"hx-vals",false,t)}function Cr(e){return le(Sr(e),Er(e))}function Tr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Rr(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return e.getAllResponseHeaders().match(t)}function Or(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||L(r,"String")){return he(e,t,null,null,{targetOverride:s(r),returnPromise:true})}else{return he(e,t,s(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:s(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function qr(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function Hr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Ar;var D=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ge(n);if(u==null||u==ve){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var X=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:X,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var m=ne(n,"hx-sync");var p=null;var y=false;if(m){var B=m.split(":");var F=B[0].trim();if(F==="this"){g=de(n,"hx-sync")}else{g=ue(n,F)}m=(B[1]||"drop").trim();f=ae(g);if(m==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(m==="abort"){if(f.xhr){ie(o);return l}else{y=true}}else if(m==="replace"){ce(g,"htmx:abort")}else if(m.indexOf("queue")===0){var V=m.split(" ");p=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(p==null){if(i){var x=ae(i);if(x&&x.triggerSpec&&x.triggerSpec.queue){p=x.triggerSpec.queue}}if(p==null){p="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(p==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=y;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=vr(n,u,S);if(t!=="get"&&!pr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=ur(n,t);var C=_.errors;var T=_.values;if(a.values){T=le(T,a.values)}var z=Cr(n);var W=le(T,z);var R=dr(W,n);if(Q.config.getCacheBusterParam&&t==="get"){R["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=br(n,"hx-request");var $=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:$,useUrlParams:q,parameters:R,unfilteredParameters:W,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;R=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(R).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=cr(R);if(L){A+="#"+L}}}if(!Hr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Tr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:$,select:D,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=qr(n);I.pathInfo.responsePath=Rr(b);M(n,I);nr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){nr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){nr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){nr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=tr(n);var P=rr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:yr(b,n,R);b.send(Y);return l}function Lr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Ar(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){Be(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){Jt();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Or("GET",r,v).then(function(){Zt(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){u.target=re().querySelector(f.getResponseHeader("HX-Retarget"))}var d=Lr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var m=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:m},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;m=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){et(l)}T(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){Jt()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=mr(l,s);if(v.hasOwnProperty("ignoreTitle")){m=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var p=null;var y=null;var x=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){Zt(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{Kt(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=R(c);Ue(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}Be(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!m){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}xr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}Be(f,"HX-Trigger-After-Settle",r)}ie(p)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(y);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){p=e;y=t});var S=x;x=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(x,v.swapDelay)}else{x()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Nr={};function Ir(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function kr(e,t){if(t.init){t.init(r)}Nr[e]=le(Ir(),t)}function Pr(e){delete Nr[e]}function Mr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Nr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Mr(u(e),r,n)}function Dr(e){var t=function(){if(!e)return;e();e=null};if(re().readyState==="complete"){t()}else{re().addEventListener("DOMContentLoaded",function(){t()});re().addEventListener("readystatechange",function(){if(re().readyState!=="complete")return;t()})}}function Xr(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function Ur(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function Br(){var e=Ur();if(e){Q.config=le(Q.config,e)}}Dr(function(){Br();Xr();var e=re().body;Bt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});var r=window.onpopstate;window.onpopstate=function(e){if(e.state&&e.state.htmx){er();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()});
\ No newline at end of file
diff --git a/static/readme.txt b/static/readme.txt
new file mode 100644
index 0000000..508090d
--- /dev/null
+++ b/static/readme.txt
@@ -0,0 +1,3 @@
+This folder is here instead of `cmd/ui/static` so editor integration suggests
+files from here when typing /static URL paths.
+Also, no, I don't care that this file and `static.go` are accessible on HTTP.
diff --git a/static/static.go b/static/static.go
new file mode 100644
index 0000000..1e98588
--- /dev/null
+++ b/static/static.go
@@ -0,0 +1,15 @@
+package static
+
+import (
+ "embed"
+ "net/http"
+)
+
+//go:generate npx tailwindcss -o tailwind.css --content "../cmd/web/ui/**/*"
+
+//go:embed *
+var files embed.FS
+
+func Server() http.HandlerFunc {
+ return http.StripPrefix("/static/", http.FileServer(http.FS(files))).ServeHTTP
+}
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..6fe3f5f
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,40 @@
+body {
+ height: 100%;
+}
+
+footer {
+ position: absolute;
+ bottom: 0;
+ border-top: 1px solid black;
+ width: 100%;
+}
+
+.wrapper {
+ max-width: 960px;
+ margin: 0 auto;
+ margin-top: 10px;
+}
+
+@media (max-width: 960px) {
+ .wrapper-inner {
+ margin-left: 10px;
+ }
+}
+
+nav {
+ border-bottom: 1px solid black;
+}
+
+@media (max-width: 768px) {
+ .wrapper-inner {
+ margin-left: 10px;
+ }
+
+ nav div ul:has(~ input) {
+ display: none !important;
+ }
+
+ nav div ul:has(~ input:checked) {
+ display: unset !important;
+ }
+}
diff --git a/static/tailwind.css b/static/tailwind.css
new file mode 100644
index 0000000..8118c95
--- /dev/null
+++ b/static/tailwind.css
@@ -0,0 +1,966 @@
+/*
+! tailwindcss v3.3.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+6. Use the user's configured `sans` font-variation-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+ font-variation-settings: normal;
+ /* 6 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font-family by default.
+2. Use the user's configured `mono` font-feature-settings by default.
+3. Use the user's configured `mono` font-variation-settings by default.
+4. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-feature-settings: normal;
+ /* 2 */
+ font-variation-settings: normal;
+ /* 3 */
+ font-size: 1em;
+ /* 4 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-feature-settings: inherit;
+ /* 1 */
+ font-variation-settings: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Reset default styling for dialogs.
+*/
+
+dialog {
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
+.static {
+ position: static;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-6 {
+ margin-bottom: 1.5rem;
+}
+
+.mt-4 {
+ margin-top: 1rem;
+}
+
+.block {
+ display: block;
+}
+
+.inline-block {
+ display: inline-block;
+}
+
+.flex {
+ display: flex;
+}
+
+.inline-flex {
+ display: inline-flex;
+}
+
+.hidden {
+ display: none;
+}
+
+.h-10 {
+ height: 2.5rem;
+}
+
+.h-5 {
+ height: 1.25rem;
+}
+
+.h-8 {
+ height: 2rem;
+}
+
+.w-10 {
+ width: 2.5rem;
+}
+
+.w-5 {
+ width: 1.25rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.max-w-screen-xl {
+ max-width: 1280px;
+}
+
+.max-w-sm {
+ max-width: 24rem;
+}
+
+.appearance-none {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+.flex-col {
+ flex-direction: column;
+}
+
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.space-x-3 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 0;
+ margin-right: calc(0.75rem * var(--tw-space-x-reverse));
+ margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
+}
+
+.rounded {
+ border-radius: 0.25rem;
+}
+
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
+.border {
+ border-width: 1px;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-gray-100 {
+ --tw-border-opacity: 1;
+ border-color: rgb(243 244 246 / var(--tw-border-opacity));
+}
+
+.border-gray-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(229 231 235 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-gray-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(229 231 235 / var(--tw-bg-opacity));
+}
+
+.bg-gray-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(249 250 251 / var(--tw-bg-opacity));
+}
+
+.bg-white {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity));
+}
+
+.p-2 {
+ padding: 0.5rem;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.py-2 {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
+.pr-4 {
+ padding-right: 1rem;
+}
+
+.align-baseline {
+ vertical-align: baseline;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.leading-tight {
+ line-height: 1.25;
+}
+
+.text-blue-500 {
+ --tw-text-opacity: 1;
+ color: rgb(59 130 246 / var(--tw-text-opacity));
+}
+
+.text-blue-600 {
+ --tw-text-opacity: 1;
+ color: rgb(37 99 235 / var(--tw-text-opacity));
+}
+
+.text-gray-500 {
+ --tw-text-opacity: 1;
+ color: rgb(107 114 128 / var(--tw-text-opacity));
+}
+
+.text-gray-700 {
+ --tw-text-opacity: 1;
+ color: rgb(55 65 81 / var(--tw-text-opacity));
+}
+
+.text-gray-900 {
+ --tw-text-opacity: 1;
+ color: rgb(17 24 39 / var(--tw-text-opacity));
+}
+
+.text-red-500 {
+ --tw-text-opacity: 1;
+ color: rgb(239 68 68 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.underline {
+ text-decoration-line: underline;
+}
+
+.shadow {
+ --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-gray-100:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(243 244 246 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-800:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 64 175 / var(--tw-text-opacity));
+}
+
+.focus\:bg-white:focus {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity));
+}
+
+.focus\:outline-none:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+}
+
+.focus\:ring-2:focus {
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
+}
+
+.focus\:ring-gray-200:focus {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity));
+}
+
+:is([dir="rtl"] .rtl\:space-x-reverse) > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 1;
+}
+
+@media (prefers-color-scheme: dark) {
+ .dark\:border-gray-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(55 65 81 / var(--tw-border-opacity));
+ }
+
+ .dark\:bg-gray-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(31 41 55 / var(--tw-bg-opacity));
+ }
+
+ .dark\:bg-gray-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(17 24 39 / var(--tw-bg-opacity));
+ }
+
+ .dark\:text-gray-400 {
+ --tw-text-opacity: 1;
+ color: rgb(156 163 175 / var(--tw-text-opacity));
+ }
+
+ .dark\:text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+ }
+
+ .dark\:hover\:bg-gray-700:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(55 65 81 / var(--tw-bg-opacity));
+ }
+
+ .dark\:hover\:text-white:hover {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+ }
+
+ .dark\:focus\:ring-gray-600:focus {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
+ }
+}
+
+@media (min-width: 768px) {
+ .md\:mb-0 {
+ margin-bottom: 0px;
+ }
+
+ .md\:mt-0 {
+ margin-top: 0px;
+ }
+
+ .md\:flex {
+ display: flex;
+ }
+
+ .md\:hidden {
+ display: none;
+ }
+
+ .md\:w-1\/3 {
+ width: 33.333333%;
+ }
+
+ .md\:w-2\/3 {
+ width: 66.666667%;
+ }
+
+ .md\:flex-row {
+ flex-direction: row;
+ }
+
+ .md\:items-center {
+ align-items: center;
+ }
+
+ .md\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 0;
+ margin-right: calc(2rem * var(--tw-space-x-reverse));
+ margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));
+ }
+
+ .md\:border-0 {
+ border-width: 0px;
+ }
+
+ .md\:bg-white {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity));
+ }
+
+ .md\:p-0 {
+ padding: 0px;
+ }
+
+ .md\:text-right {
+ text-align: right;
+ }
+
+ .md\:hover\:bg-transparent:hover {
+ background-color: transparent;
+ }
+
+ .md\:hover\:text-blue-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(29 78 216 / var(--tw-text-opacity));
+ }
+
+ @media (prefers-color-scheme: dark) {
+ .md\:dark\:bg-gray-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(17 24 39 / var(--tw-bg-opacity));
+ }
+
+ .md\:dark\:hover\:bg-transparent:hover {
+ background-color: transparent;
+ }
+
+ .md\:dark\:hover\:text-blue-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(59 130 246 / var(--tw-text-opacity));
+ }
+ }
+}
\ No newline at end of file