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 }} +
+

Log In

+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ + + Forgot Password? + +
+
+ {{ if .Data }} + {{ if .Data.Error }} +

Error: {{ .Data.Error }}

+ {{ end }} + {{ end }} +
+{{ 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 }} +
+

Reset your password

+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ {{ if .Error }} +

Error: {{ .Error }}

+ {{ end }} +
+{{ 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 }} + + + + + + + +
+
+ {{ .Data.UnsafeInnerHTML }} +
+
+ + + \ 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(/",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(""+e+"
",1);case"col":return i(""+e+"
",2);case"tr":return i(""+e+"
",2);case"td":case"th":return i(""+e+"
",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