diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..24cbd50 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +ROOT_DIR=./data diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5beae2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# binary +sprintpadawan +server +main + +# db +app.db +app.db-wal + +# env +.env + +# os +.DS_Store +.direnv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36a3ea1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM golang:alpine AS builder +RUN apk add --no-cache build-base +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -o sprintpadawan main.go + +# Runtime stage +FROM alpine:latest +RUN apk --no-cache add ca-certificates tzdata +WORKDIR /app + +ENV ROOT_DIR=/data +RUN mkdir -p /data + +COPY --from=builder /app/sprintpadawan . + +EXPOSE 8080 +CMD ["./sprintpadawan"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2dffd08 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: dev build clean + +# Run the development server +dev: + go run main.go + +# Build the standalone binary +build: + go build -o sprintpadawan main.go + +# Clean the compiled binary +clean: + rm -f sprintpadawan diff --git a/README.md b/README.md index 6d4f7fe..0daf4c1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ -# sprintpadawan +# SprintPadawan +A lightweight real-time sprint planning tool. Built with Go, HTMX, and Turso (The FOSS DB, not the platform). + +## Development + +This project uses Go 1.26. If you use Nix, a flake is provided to set up the environment. You can load it with `nix develop` or `direnv allow`. + +Available Make commands: +* `make dev`: Runs the development server. +* `make build`: Compiles everything into a single binary. +* `make clean`: Removes the binary. + +When running locally without Docker, the application will create an `app.db` SQLite database file in your current working directory. + +## Docker + +The project includes a Docker setup for those who use it. + +1. Create a `.env` file in the project root. +2. Set the `ROOT_DIR` variable to the directory on your host machine where you want the database to be saved. + +Example `.env`: +ROOT_DIR=/home/user/sprintpadawan_data + +3. Start the container: +docker compose up -d + +The Docker container maps your host `ROOT_DIR` to `/data` inside the container. Sprint Padawan is permanently configured to write its database to `/data` when running in Docker. diff --git a/VERSION.md b/VERSION.md new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION.md @@ -0,0 +1 @@ +0.2.0 diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..c6ad2a1 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,89 @@ +package api + +import ( + "log" + "net/http" + "time" + + "sprintpadawan/lib" +) + +func handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + if isLoggedIn(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + renderTemplate(w, "login.html", nil) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + user, err := lib.GetUserByUsername(username) + if err != nil || !lib.CheckPasswordHash(password, user.PasswordHash) { + renderTemplate(w, "login.html", map[string]string{"Error": "Invalid credentials"}) + return + } + + sessionID, err := lib.CreateSession(user.ID) + if err != nil { + http.Error(w, "failed to create session", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: sessionID, + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + }) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + if isLoggedIn(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + renderTemplate(w, "register.html", nil) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + confirm := r.FormValue("confirm_password") + + if password != confirm { + renderTemplate(w, "register.html", map[string]string{"Error": "Passwords do not match"}) + return + } + + if err := lib.CreateUser(username, password); err != nil { + renderTemplate(w, "register.html", map[string]string{"Error": "Username taken"}) + return + } + + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_token") + if err == nil { + if err := lib.DeleteSession(cookie.Value); err != nil { + log.Printf("delete session error: %v", err) + } + } + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + SameSite: http.SameSiteLaxMode, + Path: "/", + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/api/dashboard.go b/api/dashboard.go new file mode 100644 index 0000000..191fe72 --- /dev/null +++ b/api/dashboard.go @@ -0,0 +1,53 @@ +package api + +import ( + "log" + "net/http" + + "sprintpadawan/lib" +) + +type RoomView struct { + ID int + Name string + Code string + Scale string + IsOwner bool + MemberCount int +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + user := r.Context().Value(userKey).(*lib.User) + rooms, err := lib.GetRoomSummariesForUser(user.ID) + if err != nil { + http.Error(w, "failed to load rooms", http.StatusInternalServerError) + return + } + data := struct { + *lib.User + Rooms []RoomView + }{ + User: user, + } + + for _, room := range rooms { + data.Rooms = append(data.Rooms, RoomView{ + ID: room.ID, + Name: room.Name, + Code: room.Code, + Scale: room.Scale, + IsOwner: room.OwnerID == user.ID, + MemberCount: room.MemberCount, + }) + } + + if err := templates.ExecuteTemplate(w, "index.html", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} diff --git a/api/rooms.go b/api/rooms.go new file mode 100644 index 0000000..59607f4 --- /dev/null +++ b/api/rooms.go @@ -0,0 +1,155 @@ +package api + +import ( + "fmt" + "log" + "net/http" + + "sprintpadawan/lib" +) + +func handleNewRoom(w http.ResponseWriter, r *http.Request) { + if err := templates.ExecuteTemplate(w, "room_form.html", nil); err != nil { + log.Printf("template error: %v", err) + } +} + +func handleCreateRoom(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(userKey).(*lib.User) + name := r.FormValue("name") + scale := r.FormValue("scale") + room, err := lib.CreateRoom(name, user.ID, scale) + if err != nil { + log.Printf("create room error: %v", err) + http.Error(w, "failed to create room", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) +} + +func handleJoinRoom(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(userKey).(*lib.User) + code := r.FormValue("code") + room, err := lib.GetRoomByCode(code) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } + if err := lib.AddUserToRoom(room.ID, user.ID); err != nil { + http.Error(w, "failed to join room", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) +} + +func handleRoom(w http.ResponseWriter, r *http.Request) { + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + room, err := lib.GetRoomByID(roomID) + if err != nil { + http.NotFound(w, r) + return + } + if err := lib.AddUserToRoom(room.ID, user.ID); err != nil { + http.Error(w, "failed to join room", http.StatusInternalServerError) + return + } + + data, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } + renderTemplate(w, "room.html", data) +} + +func handlePartialStories(w http.ResponseWriter, r *http.Request) { + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + renderRoomStories(w, roomID, user) +} + +func handlePartialMembers(w http.ResponseWriter, r *http.Request) { + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + renderRoomMembers(w, roomID, user) +} + +func handlePartialVoteArea(w http.ResponseWriter, r *http.Request) { + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + room, err := lib.GetRoomByID(roomID) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } + if room.ActiveStoryID == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + story, err := lib.GetStoryByID(*room.ActiveStoryID) + if err != nil { + http.Error(w, "story not found", http.StatusNotFound) + return + } + + roomData, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } + data := struct { + RoomData RoomData + Story lib.Story + }{ + RoomData: roomData, + Story: *story, + } + renderTemplate(w, "vote-area", data) +} + +func handleDeleteRoom(w http.ResponseWriter, r *http.Request) { + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + + room, err := lib.GetRoomByID(roomID) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } + + if room.OwnerID != user.ID { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + err = lib.DeleteRoom(roomID) + if err != nil { + log.Printf("delete room error: %v", err) + http.Error(w, "failed to delete room", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..cb308bd --- /dev/null +++ b/api/routes.go @@ -0,0 +1,32 @@ +package api + +import "net/http" + +func SetupRoutes(mux *http.ServeMux) { + mux.HandleFunc("/login", handleLogin) + mux.HandleFunc("/register", handleRegister) + mux.HandleFunc("/logout", handleLogout) + + mux.HandleFunc("/", requireAuth(handleIndex)) + mux.HandleFunc("/rooms/new", requireAuth(handleNewRoom)) + mux.HandleFunc("/rooms/create", requireAuth(handleCreateRoom)) + mux.HandleFunc("/rooms/join", requireAuth(handleJoinRoom)) + mux.HandleFunc("/rooms/{id}", requireAuth(handleRoom)) + mux.HandleFunc("/rooms/{id}/delete", requireAuth(handleDeleteRoom)) + + mux.HandleFunc("/rooms/{id}/partial/stories", requireAuth(handlePartialStories)) + mux.HandleFunc("/rooms/{id}/partial/members", requireAuth(handlePartialMembers)) + mux.HandleFunc("/rooms/{id}/partial/vote-area", requireAuth(handlePartialVoteArea)) + + mux.HandleFunc("/rooms/{id}/stories/new", requireAuth(handleNewStoryForm)) + mux.HandleFunc("/rooms/{id}/stories", requireAuth(handleAddStory)) + mux.HandleFunc("/rooms/{id}/active", requireAuth(handleSetActiveStory)) + mux.HandleFunc("/rooms/{id}/vote", requireAuth(handleVote)) + mux.HandleFunc("/rooms/{id}/reveal", requireAuth(handleReveal)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/edit", requireAuth(handleEditStoryForm)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/rename", requireAuth(handleRenameStory)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/delete", requireAuth(handleDeleteStory)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/reset", requireAuth(handleResetStory)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/unreveal", requireAuth(handleUnrevealStory)) + mux.HandleFunc("/sse/{room_id}", requireAuth(handleSSE)) +} diff --git a/api/sse.go b/api/sse.go new file mode 100644 index 0000000..26f8fef --- /dev/null +++ b/api/sse.go @@ -0,0 +1,59 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "sprintpadawan/lib" +) + +func handleSSE(w http.ResponseWriter, r *http.Request) { + roomID, err := getPathInt(r, "room_id") + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user, ok := r.Context().Value(userKey).(*lib.User) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + ch := make(chan string, 10) + addSSEClient(roomID, user.ID, ch) + broadcast(roomID, "members") + + notify := r.Context().Done() + heartbeat := time.NewTicker(25 * time.Second) + defer heartbeat.Stop() + + fmt.Fprint(w, ": connected\n\n") + flusher.Flush() + + for { + select { + case <-notify: + removeSSEClient(roomID, ch) + broadcast(roomID, "members") + return + case <-heartbeat.C: + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + case event := <-ch: + fmt.Fprintf(w, "event: %s\ndata: true\n\n", event) + flusher.Flush() + } + } +} diff --git a/api/stories.go b/api/stories.go new file mode 100644 index 0000000..d5471cb --- /dev/null +++ b/api/stories.go @@ -0,0 +1,277 @@ +package api + +import ( + "fmt" + "log" + "net/http" + + "sprintpadawan/lib" +) + +func handleNewStoryForm(w http.ResponseWriter, r *http.Request) { + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + room, err := lib.GetRoomByID(roomID) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } + renderTemplate(w, "story_form.html", room) +} + +func handleAddStory(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + title := r.FormValue("title") + if _, err := lib.CreateStory(id, title); err != nil { + log.Printf("create story error: %v", err) + http.Error(w, "failed to create story", http.StatusInternalServerError) + return + } + broadcast(id, "stories") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} + +func handleSetActiveStory(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + sid, err := getFormInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + + if err := lib.SetActiveStory(id, sid); err != nil { + http.Error(w, "failed to set active story", http.StatusInternalServerError) + return + } + + broadcast(id, "stories") + broadcast(id, "members") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} + +func handleResetStory(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + + if err := lib.UnrevealStory(sid); err != nil { + http.Error(w, "failed to reset story", http.StatusInternalServerError) + return + } + if err := lib.ClearVotesForStory(sid); err != nil { + http.Error(w, "failed to clear votes", http.StatusInternalServerError) + return + } + + broadcast(id, "stories") + broadcast(id, "members") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} + +func handleVote(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + value := r.FormValue("value") + sid, err := getFormInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + + if err := lib.VoteOnStory(sid, user.ID, value); err != nil { + http.Error(w, "failed to vote", http.StatusInternalServerError) + return + } + broadcast(id, "members") + story, storyErr := lib.GetStoryByID(sid) + if storyErr == nil && story.Voted { + broadcast(id, "stories") + } + + if isHTMX(r) { + if storyErr != nil { + http.Error(w, "story not found", http.StatusNotFound) + return + } + room, err := lib.GetRoomByID(id) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } + tmplData := struct { + RoomID int + Story lib.Story + Scale string + UserVote string + }{ + RoomID: id, + Story: *story, + Scale: room.Scale, + UserVote: value, + } + renderTemplate(w, "vote_form.html", tmplData) + return + } + + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} + +func handleReveal(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + sid, err := getFormInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + if err := lib.RevealVotes(sid); err != nil { + http.Error(w, "failed to reveal votes", http.StatusInternalServerError) + return + } + broadcast(id, "stories") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} + +func handleUnrevealStory(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + if err := lib.UnrevealStory(sid); err != nil { + http.Error(w, "failed to hide votes", http.StatusInternalServerError) + return + } + broadcast(id, "stories") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} + +func handleEditStoryForm(w http.ResponseWriter, r *http.Request) { + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + storyID, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + story, err := lib.GetStoryByID(storyID) + if err != nil { + http.Error(w, "story not found", http.StatusNotFound) + return + } + data := struct { + RoomID int + Story lib.Story + }{ + RoomID: roomID, + Story: *story, + } + renderTemplate(w, "story_edit.html", data) +} + +func handleRenameStory(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + title := r.FormValue("title") + if err := lib.RenameStory(sid, title); err != nil { + http.Error(w, "failed to rename story", http.StatusInternalServerError) + return + } + broadcast(id, "stories") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} + +func handleDeleteStory(w http.ResponseWriter, r *http.Request) { + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + user := r.Context().Value(userKey).(*lib.User) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + if err := lib.DeleteStory(sid); err != nil { + http.Error(w, "failed to delete story", http.StatusInternalServerError) + return + } + broadcast(id, "stories") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} diff --git a/api/templates.go b/api/templates.go new file mode 100644 index 0000000..02c9967 --- /dev/null +++ b/api/templates.go @@ -0,0 +1,121 @@ +package api + +import ( + "context" + "fmt" + "html/template" + "io/fs" + "log" + "net/http" + + "sprintpadawan/lib" +) + +var templates *template.Template + +func InitTemplates(fsys fs.FS) { + templates = template.Must(template.New("").Funcs(template.FuncMap{ + "scaleToOptions": scaleToOptions, + "derefInt": func(i *int) int { + if i == nil { + return 0 + } + return *i + }, + "slice": func(s string, start, end int) string { + if len(s) == 0 { + return "" + } + if start < 0 { + start = 0 + } + if end > len(s) || end <= 0 { + end = len(s) + } + if start > end { + return "" + } + return s[start:end] + }, + "dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("odd number of arguments to dict") + } + d := make(map[string]interface{}) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict keys must be strings") + } + d[key] = values[i+1] + } + return d, nil + }, + }).ParseFS(fsys, "templates/*.html")) +} + +func isHTMX(r *http.Request) bool { + return r.Header.Get("HX-Request") == "true" +} + +func renderTemplate(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := templates.ExecuteTemplate(w, name, data); err != nil { + log.Printf("template error (%s): %v", name, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} + +func renderRoomStories(w http.ResponseWriter, roomID int, user *lib.User) { + room, err := lib.GetRoomByID(roomID) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } + data, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } + renderTemplate(w, "stories-panel", data) +} + +func renderRoomMembers(w http.ResponseWriter, roomID int, user *lib.User) { + room, err := lib.GetRoomByID(roomID) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } + data, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } + renderTemplate(w, "members-panel", data) +} + +func requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_token") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + user, err := lib.GetUserFromSession(cookie.Value) + if err != nil || user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), userKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} + +func isLoggedIn(r *http.Request) bool { + cookie, err := r.Cookie("session_token") + if err != nil { + return false + } + user, err := lib.GetUserFromSession(cookie.Value) + return err == nil && user != nil +} diff --git a/api/types.go b/api/types.go new file mode 100644 index 0000000..bfb35a5 --- /dev/null +++ b/api/types.go @@ -0,0 +1,233 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + "sync" + + "sprintpadawan/lib" +) + +type contextKey string + +const userKey contextKey = "user" + +type sseClient struct { + ch chan string + userID int +} + +type MemberView struct { + Username string + HasVoted bool + ID int +} + +type RoomData struct { + *lib.Room + User *lib.User + Members []MemberView + Stories []lib.Story + IsOwner bool + UserVotes map[int]string + StoryVotes map[int][]lib.VoteView +} + +var ( + roomClients = make(map[int][]*sseClient) + clientsMu sync.RWMutex +) + +func addSSEClient(roomID, userID int, ch chan string) { + clientsMu.Lock() + defer clientsMu.Unlock() + roomClients[roomID] = append(roomClients[roomID], &sseClient{ch: ch, userID: userID}) +} + +func removeSSEClient(roomID int, ch chan string) { + clientsMu.Lock() + defer clientsMu.Unlock() + clients := roomClients[roomID] + for i, c := range clients { + if c.ch == ch { + roomClients[roomID] = append(clients[:i], clients[i+1:]...) + break + } + } + if len(roomClients[roomID]) == 0 { + delete(roomClients, roomID) + } +} + +func broadcast(roomID int, event string) { + clientsMu.RLock() + snapshot := append([]*sseClient(nil), roomClients[roomID]...) + clientsMu.RUnlock() + + if len(snapshot) == 0 { + return + } + + deadSet := make(map[*sseClient]struct{}) + for _, c := range snapshot { + select { + case c.ch <- event: + default: + deadSet[c] = struct{}{} + } + } + + if len(deadSet) == 0 { + return + } + + clientsMu.Lock() + defer clientsMu.Unlock() + + current := roomClients[roomID] + alive := current[:0] + for _, c := range current { + if _, dead := deadSet[c]; dead { + continue + } + alive = append(alive, c) + } + if len(alive) == 0 { + delete(roomClients, roomID) + return + } + roomClients[roomID] = alive +} + +func GetConnectedUserIDs(roomID int) []int { + clientsMu.RLock() + defer clientsMu.RUnlock() + seen := make(map[int]bool) + var ids []int + for _, c := range roomClients[roomID] { + if !seen[c.userID] { + seen[c.userID] = true + ids = append(ids, c.userID) + } + } + return ids +} + +func scaleToOptions(scale string) []string { + switch scale { + case "fibonacci": + return []string{"1", "2", "3", "5", "8", "13", "21", "?"} + case "tshirt": + return []string{"XS", "S", "M", "L", "XL", "XXL", "?"} + case "linear": + return []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "?"} + default: + return []string{"1", "2", "3", "5", "8", "13", "21", "?"} + } +} + +func getRoomID(r *http.Request) (int, error) { + return getPathInt(r, "id") +} + +func getPathInt(r *http.Request, key string) (int, error) { + raw := r.PathValue(key) + if raw == "" { + return 0, errors.New("missing path parameter") + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, errors.New("invalid path parameter") + } + return id, nil +} + +func getFormInt(r *http.Request, key string) (int, error) { + raw := r.FormValue(key) + if raw == "" { + return 0, errors.New("missing form value") + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, errors.New("invalid form value") + } + return id, nil +} + +func buildRoomData(room *lib.Room, user *lib.User) (RoomData, error) { + members, err := lib.GetRoomMembers(room.ID) + if err != nil { + return RoomData{}, err + } + + stories, err := lib.GetStoriesForRoom(room.ID) + if err != nil { + return RoomData{}, err + } + + connectedIDs := GetConnectedUserIDs(room.ID) + connectedMap := make(map[int]bool) + for _, cid := range connectedIDs { + connectedMap[cid] = true + } + connectedMap[user.ID] = true + + storyIDs := make([]int, 0, len(stories)) + revealedStoryIDs := make([]int, 0, len(stories)) + for _, s := range stories { + storyIDs = append(storyIDs, s.ID) + if s.Voted { + revealedStoryIDs = append(revealedStoryIDs, s.ID) + } + } + + votesByStory, err := lib.GetVotesForStories(storyIDs) + if err != nil { + return RoomData{}, err + } + + storyVotes, err := lib.GetVoteViewsForStories(revealedStoryIDs) + if err != nil { + return RoomData{}, err + } + + activeVotesByUser := make(map[int]bool) + if room.ActiveStoryID != nil { + for _, v := range votesByStory[*room.ActiveStoryID] { + activeVotesByUser[v.UserID] = true + } + } + + var memberViews []MemberView + for _, m := range members { + if !connectedMap[m.ID] { + continue + } + hasVoted := activeVotesByUser[m.ID] + memberViews = append(memberViews, MemberView{ + Username: m.Username, + HasVoted: hasVoted, + ID: m.ID, + }) + } + + userVotes := make(map[int]string) + for _, s := range stories { + for _, v := range votesByStory[s.ID] { + if v.UserID == user.ID { + userVotes[s.ID] = v.Value + } + } + } + + return RoomData{ + Room: room, + User: user, + Members: memberViews, + Stories: stories, + IsOwner: room.OwnerID == user.ID, + UserVotes: userVotes, + StoryVotes: storyVotes, + }, nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..09a7b7b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + app: + build: . + container_name: sprintpadawan + ports: + - "8080:8080" + volumes: + - ${ROOT_DIR:-./data}:/data + restart: unless-stopped diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3f06416 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1777268161, + "narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "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/flake.nix b/flake.nix new file mode 100644 index 0000000..1368f54 --- /dev/null +++ b/flake.nix @@ -0,0 +1,39 @@ +{ + description = "SprintPadawan Go 1.26 development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = [ + pkgs.go + pkgs.gopls + pkgs.gotools + pkgs.go-tools + pkgs.gnumake + ]; + + shellHook = '' + echo "SprintPadawan Dev Environment Loaded" + go version + ''; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..586dc37 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module sprintpadawan + +go 1.26.1 + +require ( + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.50.0 + turso.tech/database/tursogo v0.5.3 +) + +require ( + github.com/ebitengine/purego v0.10.0 // indirect + github.com/tursodatabase/turso-go-platform-libs v0.5.3 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..560dcda --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tursodatabase/turso-go-platform-libs v0.5.3 h1:JvlE+vC2IbyVrxlAVuPoBKvDCQF9hX4c17amhthNqSg= +github.com/tursodatabase/turso-go-platform-libs v0.5.3/go.mod h1:bo+Lpv5OYOX1gRV9L5DLKMsYxmDs56SkZwnCOLEFcxU= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +turso.tech/database/tursogo v0.5.3 h1:ICh0AtACo8aVxPj/wsqCnT8Erl/WkNYuhy59/L5RDTI= +turso.tech/database/tursogo v0.5.3/go.mod h1:JjsqX4S3fT1arHjqKuiuGxhJQE+VlYboU5YVqHOjy6s= diff --git a/lib/db.go b/lib/db.go new file mode 100644 index 0000000..ed26ffa --- /dev/null +++ b/lib/db.go @@ -0,0 +1,134 @@ +package lib + +import ( + "database/sql" + "log" + "os" + "path/filepath" + + _ "turso.tech/database/tursogo" +) + +var DB *sql.DB + +// init sqlite db — creates app.db at project root (run from root) or ROOT_DIR if set +func InitDB() { + var err error + + dbPath := "app.db" + if rootDir := os.Getenv("ROOT_DIR"); rootDir != "" { + dbPath = filepath.Join(rootDir, "app.db") + } + + DB, err = sql.Open("turso", dbPath) + if err != nil { + log.Fatal(err) + } + + if _, err = DB.Exec("PRAGMA foreign_keys = ON;"); err != nil { + log.Fatal(err) + } + + // make users table + userTable := ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL + );` + _, err = DB.Exec(userTable) + if err != nil { + log.Fatal(err) + } + + // make sessions table + sessionTable := ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );` + _, err = DB.Exec(sessionTable) + if err != nil { + log.Fatal(err) + } + + // make rooms table + roomTable := ` + CREATE TABLE IF NOT EXISTS rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + code TEXT UNIQUE NOT NULL, + scale TEXT NOT NULL DEFAULT 'fibonacci', + owner_id INTEGER NOT NULL, + created_at DATETIME NOT NULL, + active_story_id INTEGER, + FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE + );` + _, err = DB.Exec(roomTable) + if err != nil { + log.Fatal(err) + } + + // make room_members table + memberTable := ` + CREATE TABLE IF NOT EXISTS room_members ( + room_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + joined_at DATETIME NOT NULL, + PRIMARY KEY (room_id, user_id), + FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );` + _, err = DB.Exec(memberTable) + if err != nil { + log.Fatal(err) + } + + // make stories table + storyTable := ` + CREATE TABLE IF NOT EXISTS stories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + title TEXT NOT NULL, + points INTEGER, + voted INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE CASCADE + );` + _, err = DB.Exec(storyTable) + if err != nil { + log.Fatal(err) + } + + // make votes table + voteTable := ` + CREATE TABLE IF NOT EXISTS votes ( + story_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (story_id, user_id), + FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );` + _, err = DB.Exec(voteTable) + if err != nil { + log.Fatal(err) + } + + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_sessions_user_expires ON sessions(user_id, expires_at);", + "CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);", + "CREATE INDEX IF NOT EXISTS idx_rooms_owner ON rooms(owner_id);", + "CREATE INDEX IF NOT EXISTS idx_room_members_room ON room_members(room_id);", + "CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);", + "CREATE INDEX IF NOT EXISTS idx_stories_room ON stories(room_id);", + "CREATE INDEX IF NOT EXISTS idx_votes_story ON votes(story_id);", + "CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);", + } + for _, stmt := range indexes { + if _, err = DB.Exec(stmt); err != nil { + log.Fatal(err) + } + } +} diff --git a/lib/room.go b/lib/room.go new file mode 100644 index 0000000..34e744a --- /dev/null +++ b/lib/room.go @@ -0,0 +1,248 @@ +package lib + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +type Room struct { + ID int + Name string + Code string + Scale string + OwnerID int + CreatedAt time.Time + ActiveStoryID *int +} + +type RoomMember struct { + RoomID int + UserID int + JoinedAt time.Time +} + +type RoomSummary struct { + Room + MemberCount int +} + +// generate unique room code +func generateRoomCode() string { + code := uuid.New().String()[:8] + return code +} + +// create room +func CreateRoom(name string, ownerID int, scale string) (*Room, error) { + code := generateRoomCode() + + tx, err := DB.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + createdAt := time.Now() + res, err := tx.Exec("INSERT INTO rooms (name, code, scale, owner_id, created_at, active_story_id) VALUES (?, ?, ?, ?, ?, NULL)", name, code, scale, ownerID, createdAt) + if err != nil { + return nil, err + } + + roomID64, err := res.LastInsertId() + if err != nil { + return nil, err + } + roomID := int(roomID64) + + if _, err := tx.Exec("INSERT INTO room_members (room_id, user_id, joined_at) VALUES (?, ?, ?)", roomID, ownerID, createdAt); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + return &Room{ + ID: roomID, + Name: name, + Code: code, + Scale: scale, + OwnerID: ownerID, + CreatedAt: createdAt, + ActiveStoryID: nil, + }, nil +} + +// get room by id +func GetRoomByID(id int) (*Room, error) { + row := DB.QueryRow("SELECT id, name, code, scale, owner_id, created_at, active_story_id FROM rooms WHERE id = ?", id) + r := &Room{} + err := row.Scan(&r.ID, &r.Name, &r.Code, &r.Scale, &r.OwnerID, &r.CreatedAt, &r.ActiveStoryID) + if err != nil { + return nil, err + } + return r, nil +} + +// get room by code +func GetRoomByCode(code string) (*Room, error) { + row := DB.QueryRow("SELECT id, name, code, scale, owner_id, created_at, active_story_id FROM rooms WHERE code = ?", code) + r := &Room{} + err := row.Scan(&r.ID, &r.Name, &r.Code, &r.Scale, &r.OwnerID, &r.CreatedAt, &r.ActiveStoryID) + if err != nil { + return nil, err + } + return r, nil +} + +// get rooms for user +func GetRoomsForUser(userID int) ([]Room, error) { + summaries, err := GetRoomSummariesForUser(userID) + if err != nil { + return nil, err + } + rooms := make([]Room, 0, len(summaries)) + for _, s := range summaries { + rooms = append(rooms, s.Room) + } + return rooms, nil +} + +func GetRoomSummariesForUser(userID int) ([]RoomSummary, error) { + rows, err := DB.Query(` + SELECT r.id, r.name, r.code, r.scale, r.owner_id, r.created_at, r.active_story_id, COUNT(rm.user_id) as member_count + FROM rooms r + LEFT JOIN room_members rm ON r.id = rm.room_id + WHERE r.owner_id = ? + GROUP BY r.id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var rooms []RoomSummary + for rows.Next() { + var r RoomSummary + var memberCount int + err := rows.Scan(&r.ID, &r.Name, &r.Code, &r.Scale, &r.OwnerID, &r.CreatedAt, &r.ActiveStoryID, &memberCount) + if err != nil { + return nil, err + } + r.MemberCount = memberCount + rooms = append(rooms, r) + } + return rooms, nil +} + +// set active story for room +func SetActiveStory(roomID, storyID int) error { + _, err := DB.Exec("UPDATE rooms SET active_story_id = ? WHERE id = ?", storyID, roomID) + return err +} + +// add user to room +func AddUserToRoom(roomID, userID int) error { + _, err := DB.Exec("INSERT OR IGNORE INTO room_members (room_id, user_id, joined_at) VALUES (?, ?, ?)", roomID, userID, time.Now()) + return err +} + +// get room members +func GetRoomMembers(roomID int) ([]User, error) { + rows, err := DB.Query(` + SELECT u.id, u.username, u.password_hash + FROM users u + JOIN room_members rm ON u.id = rm.user_id + WHERE rm.room_id = ? + `, roomID) + if err != nil { + return nil, err + } + defer rows.Close() + var members []User + for rows.Next() { + var u User + err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash) + if err != nil { + return nil, err + } + members = append(members, u) + } + return members, nil +} + +// delete room by id +func DeleteRoom(id int) error { + _, err := DB.Exec("DELETE FROM rooms WHERE id = ?", id) + return err +} + +func GetVotesForStories(storyIDs []int) (map[int][]Vote, error) { + votesByStory := make(map[int][]Vote) + if len(storyIDs) == 0 { + return votesByStory, nil + } + + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(storyIDs)), ",") + args := make([]interface{}, 0, len(storyIDs)) + for _, id := range storyIDs { + args = append(args, id) + } + + query := fmt.Sprintf("SELECT story_id, user_id, value FROM votes WHERE story_id IN (%s)", placeholders) + rows, err := DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var v Vote + if err := rows.Scan(&v.StoryID, &v.UserID, &v.Value); err != nil { + return nil, err + } + votesByStory[v.StoryID] = append(votesByStory[v.StoryID], v) + } + + return votesByStory, nil +} + +func GetVoteViewsForStories(storyIDs []int) (map[int][]VoteView, error) { + voteViewsByStory := make(map[int][]VoteView) + if len(storyIDs) == 0 { + return voteViewsByStory, nil + } + + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(storyIDs)), ",") + args := make([]interface{}, 0, len(storyIDs)) + for _, id := range storyIDs { + args = append(args, id) + } + + query := fmt.Sprintf(` + SELECT v.story_id, u.username, v.value + FROM votes v + JOIN users u ON v.user_id = u.id + WHERE v.story_id IN (%s) + `, placeholders) + rows, err := DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var ( + storyID int + vv VoteView + ) + if err := rows.Scan(&storyID, &vv.Username, &vv.Value); err != nil { + return nil, err + } + voteViewsByStory[storyID] = append(voteViewsByStory[storyID], vv) + } + + return voteViewsByStory, nil +} diff --git a/lib/story.go b/lib/story.go new file mode 100644 index 0000000..8e9afdf --- /dev/null +++ b/lib/story.go @@ -0,0 +1,189 @@ +package lib + +import ( + "database/sql" + "strconv" +) + +type Story struct { + ID int + RoomID int + Title string + Points *int + Voted bool +} + +type Vote struct { + ID int + StoryID int + UserID int + Value string +} + +// create story +func CreateStory(roomID int, title string) (*Story, error) { + res, err := DB.Exec("INSERT INTO stories (room_id, title, voted) VALUES (?, ?, 0)", roomID, title) + if err != nil { + return nil, err + } + id64, err := res.LastInsertId() + if err != nil { + return nil, err + } + return &Story{ + ID: int(id64), + RoomID: roomID, + Title: title, + Points: nil, + Voted: false, + }, nil +} + +// get stories for room +func GetStoriesForRoom(roomID int) ([]Story, error) { + rows, err := DB.Query("SELECT id, room_id, title, points, voted FROM stories WHERE room_id = ? ORDER BY id", roomID) + if err != nil { + return nil, err + } + defer rows.Close() + var stories []Story + for rows.Next() { + var s Story + var points sql.NullInt64 + var voted int + err := rows.Scan(&s.ID, &s.RoomID, &s.Title, &points, &voted) + if err != nil { + return nil, err + } + if points.Valid { + p := int(points.Int64) + s.Points = &p + } + s.Voted = voted == 1 + stories = append(stories, s) + } + return stories, nil +} + +// vote on story +func VoteOnStory(storyID, userID int, value string) error { + _, err := DB.Exec("INSERT OR REPLACE INTO votes (story_id, user_id, value) VALUES (?, ?, ?)", storyID, userID, value) + return err +} + +// get votes for story +func GetVotesForStory(storyID int) ([]Vote, error) { + rows, err := DB.Query("SELECT story_id, user_id, value FROM votes WHERE story_id = ?", storyID) + if err != nil { + return nil, err + } + defer rows.Close() + var votes []Vote + for rows.Next() { + var v Vote + err := rows.Scan(&v.StoryID, &v.UserID, &v.Value) + if err != nil { + return nil, err + } + votes = append(votes, v) + } + return votes, nil +} + +// reveal votes — just marks the story as revealed +func RevealVotes(storyID int) error { + _, err := DB.Exec("UPDATE stories SET voted = 1 WHERE id = ?", storyID) + return err +} + +type VoteView struct { + Username string + Value string +} + +// get votes with usernames for display +func GetVotesWithUsernames(storyID int) ([]VoteView, error) { + rows, err := DB.Query(` + SELECT u.username, v.value + FROM votes v + JOIN users u ON v.user_id = u.id + WHERE v.story_id = ? + `, storyID) + if err != nil { + return nil, err + } + defer rows.Close() + var views []VoteView + for rows.Next() { + var vv VoteView + err := rows.Scan(&vv.Username, &vv.Value) + if err != nil { + return nil, err + } + views = append(views, vv) + } + return views, nil +} + +// unreveal story — set voted back to 0 and clear points +func UnrevealStory(storyID int) error { + _, err := DB.Exec("UPDATE stories SET voted = 0, points = NULL WHERE id = ?", storyID) + return err +} + +// clear all votes for a story +func ClearVotesForStory(storyID int) error { + _, err := DB.Exec("DELETE FROM votes WHERE story_id = ?", storyID) + return err +} + +// rename story +func RenameStory(storyID int, title string) error { + _, err := DB.Exec("UPDATE stories SET title = ? WHERE id = ?", title, storyID) + return err +} + +// delete story and its votes +func DeleteStory(storyID int) error { + _, err := DB.Exec("DELETE FROM stories WHERE id = ?", storyID) + return err +} + +func tshirtToPoints(s string) float64 { + switch s { + case "XS": + return 1 + case "S": + return 3 + case "M": + return 5 + case "L": + return 8 + case "XL": + return 13 + case "XXL": + return 21 + case "?": + return 0 + default: + n, _ := strconv.Atoi(s) + return float64(n) + } +} + +func GetStoryByID(id int) (*Story, error) { + row := DB.QueryRow("SELECT id, room_id, title, points, voted FROM stories WHERE id = ?", id) + var s Story + var points sql.NullInt64 + var voted int + err := row.Scan(&s.ID, &s.RoomID, &s.Title, &points, &voted) + if err != nil { + return nil, err + } + if points.Valid { + p := int(points.Int64) + s.Points = &p + } + s.Voted = voted == 1 + return &s, nil +} diff --git a/lib/user.go b/lib/user.go new file mode 100644 index 0000000..add0252 --- /dev/null +++ b/lib/user.go @@ -0,0 +1,82 @@ +package lib + +import ( + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +type User struct { + ID int + Username string + PasswordHash string +} + +// hash password +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return string(bytes), err +} + +// check password +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// create new user +func CreateUser(username, password string) error { + hash, err := HashPassword(password) + if err != nil { + return err + } + _, err = DB.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)", username, hash) + return err +} + +// get user by name +func GetUserByUsername(username string) (*User, error) { + row := DB.QueryRow("SELECT id, username, password_hash FROM users WHERE username = ?", username) + u := &User{} + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash) + if err != nil { + return nil, err + } + return u, nil +} + +// create session token +func CreateSession(userID int) (string, error) { + sessionID := uuid.New().String() + expiresAt := time.Now().Add(24 * time.Hour) + + _, err := DB.Exec("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)", sessionID, userID, expiresAt) + if err != nil { + return "", err + } + return sessionID, nil +} + +// get user from session +func GetUserFromSession(sessionID string) (*User, error) { + row := DB.QueryRow(` + SELECT u.id, u.username, u.password_hash + FROM users u + JOIN sessions s ON u.id = s.user_id + WHERE s.id = ? AND s.expires_at > ? + `, sessionID, time.Now()) + + u := &User{} + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash) + if err != nil { + return nil, err + } + return u, nil +} + +// delete session +func DeleteSession(sessionID string) error { + _, err := DB.Exec("DELETE FROM sessions WHERE id = ?", sessionID) + return err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..18ee94c --- /dev/null +++ b/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "compress/gzip" + "embed" + "io" + "log" + "net/http" + "path/filepath" + "regexp" + "strings" + + "github.com/joho/godotenv" + + "sprintpadawan/api" + "sprintpadawan/lib" +) + +type gzipResponseWriter struct { + io.Writer + http.ResponseWriter +} + +var fingerprintPattern = regexp.MustCompile(`[-.][a-f0-9]{8,}\.`) + +func (w gzipResponseWriter) Write(b []byte) (int, error) { + return w.Writer.Write(b) +} + +func gzipMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Vary", "Accept-Encoding") + + gz := gzip.NewWriter(w) + defer gz.Close() + + gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w} + next.ServeHTTP(gzw, r) + }) +} + +func cacheMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fingerprintPattern.MatchString(filepath.Base(r.URL.Path)) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + next.ServeHTTP(w, r) + }) +} + +//go:embed static templates +var embeddedFiles embed.FS + +func main() { + // load .env file if it exists + _ = godotenv.Load() + + lib.InitDB() + api.InitTemplates(embeddedFiles) + + mux := http.NewServeMux() + + // serve static assets + staticHandler := http.FileServer(http.FS(embeddedFiles)) + mux.Handle("/static/", cacheMiddleware(gzipMiddleware(staticHandler))) + + api.SetupRoutes(mux) + + addr := ":8080" + log.Printf("Starting SprintPadawan server on http://localhost%s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/static/fonts/JetBrainsMono-Medium.ttf b/static/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..51ccd91 Binary files /dev/null and b/static/fonts/JetBrainsMono-Medium.ttf differ diff --git a/static/fonts/JetBrainsMono-Regular.ttf b/static/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..129e882 Binary files /dev/null and b/static/fonts/JetBrainsMono-Regular.ttf differ diff --git a/static/fonts/Outfit-Bold.ttf b/static/fonts/Outfit-Bold.ttf new file mode 100644 index 0000000..0389ff2 Binary files /dev/null and b/static/fonts/Outfit-Bold.ttf differ diff --git a/static/fonts/Outfit-Medium.ttf b/static/fonts/Outfit-Medium.ttf new file mode 100644 index 0000000..0ba786a Binary files /dev/null and b/static/fonts/Outfit-Medium.ttf differ diff --git a/static/fonts/Outfit-SemiBold.ttf b/static/fonts/Outfit-SemiBold.ttf new file mode 100644 index 0000000..52e25bc Binary files /dev/null and b/static/fonts/Outfit-SemiBold.ttf differ diff --git a/static/fonts/PlusJakartaSans-Bold.ttf b/static/fonts/PlusJakartaSans-Bold.ttf new file mode 100644 index 0000000..91ab258 Binary files /dev/null and b/static/fonts/PlusJakartaSans-Bold.ttf differ diff --git a/static/fonts/PlusJakartaSans-Medium.ttf b/static/fonts/PlusJakartaSans-Medium.ttf new file mode 100644 index 0000000..6e7af77 Binary files /dev/null and b/static/fonts/PlusJakartaSans-Medium.ttf differ diff --git a/static/fonts/PlusJakartaSans-Regular.ttf b/static/fonts/PlusJakartaSans-Regular.ttf new file mode 100644 index 0000000..b655eb4 Binary files /dev/null and b/static/fonts/PlusJakartaSans-Regular.ttf differ diff --git a/static/fonts/PlusJakartaSans-SemiBold.ttf b/static/fonts/PlusJakartaSans-SemiBold.ttf new file mode 100644 index 0000000..609def9 Binary files /dev/null and b/static/fonts/PlusJakartaSans-SemiBold.ttf differ diff --git a/static/fonts/fonts.css b/static/fonts/fonts.css new file mode 100644 index 0000000..ee76a17 --- /dev/null +++ b/static/fonts/fonts.css @@ -0,0 +1,63 @@ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/fonts/JetBrainsMono-Regular.ttf) format('truetype'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/static/fonts/JetBrainsMono-Medium.ttf) format('truetype'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/static/fonts/Outfit-Medium.ttf) format('truetype'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/static/fonts/Outfit-SemiBold.ttf) format('truetype'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/static/fonts/Outfit-Bold.ttf) format('truetype'); +} +@font-face { + font-family: 'Plus Jakarta Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/fonts/PlusJakartaSans-Regular.ttf) format('truetype'); +} +@font-face { + font-family: 'Plus Jakarta Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/static/fonts/PlusJakartaSans-Medium.ttf) format('truetype'); +} +@font-face { + font-family: 'Plus Jakarta Sans'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/static/fonts/PlusJakartaSans-SemiBold.ttf) format('truetype'); +} +@font-face { + font-family: 'Plus Jakarta Sans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/static/fonts/PlusJakartaSans-Bold.ttf) format('truetype'); +} diff --git a/static/img/favicon.ico b/static/img/favicon.ico new file mode 100644 index 0000000..f06ae95 Binary files /dev/null and b/static/img/favicon.ico differ diff --git a/static/img/logo.png b/static/img/logo.png new file mode 100644 index 0000000..c79234d Binary files /dev/null and b/static/img/logo.png differ diff --git a/static/img/logo.webp b/static/img/logo.webp new file mode 100644 index 0000000..509f734 Binary files /dev/null and b/static/img/logo.webp differ diff --git a/static/js/htmx.min.js b/static/js/htmx.min.js new file mode 100644 index 0000000..753c4ca --- /dev/null +++ b/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx = function () { "use strict"; const Q = { onLoad: null, process: null, on: null, off: null, trigger: null, ajax: null, find: null, findAll: null, closest: null, values: function (e, t) { const n = cn(e, t || "post"); return n.values }, remove: null, addClass: null, removeClass: null, toggleClass: null, takeClass: null, swap: null, defineExtension: null, removeExtension: null, logAll: null, logNone: null, logger: null, config: { historyEnabled: true, historyCacheSize: 10, refreshOnHistoryMiss: false, defaultSwapStyle: "innerHTML", defaultSwapDelay: 0, defaultSettleDelay: 20, includeIndicatorStyles: true, indicatorClass: "htmx-indicator", requestClass: "htmx-request", addedClass: "htmx-added", settlingClass: "htmx-settling", swappingClass: "htmx-swapping", allowEval: true, allowScriptTags: true, inlineScriptNonce: "", inlineStyleNonce: "", attributesToSettle: ["class", "style", "width", "height"], withCredentials: false, timeout: 0, wsReconnectDelay: "full-jitter", wsBinaryType: "blob", disableSelector: "[hx-disable], [data-hx-disable]", scrollBehavior: "instant", defaultFocusScroll: false, getCacheBusterParam: false, globalViewTransitions: false, methodsThatUseUrlParams: ["get", "delete"], selfRequestsOnly: true, ignoreTitle: false, scrollIntoViewOnBoost: true, triggerSpecsCache: null, disableInheritance: false, responseHandling: [{ code: "204", swap: false }, { code: "[23]..", swap: true }, { code: "[45]..", swap: false, error: true }], allowNestedOobSwaps: true }, parseInterval: null, _: null, version: "2.0.4" }; Q.onLoad = j; Q.process = kt; Q.on = ye; Q.off = be; Q.trigger = he; Q.ajax = Rn; Q.find = u; Q.findAll = x; Q.closest = g; Q.remove = z; Q.addClass = K; Q.removeClass = G; Q.toggleClass = W; Q.takeClass = Z; Q.swap = $e; Q.defineExtension = Fn; Q.removeExtension = Bn; Q.logAll = V; Q.logNone = _; Q.parseInterval = d; Q._ = e; const n = { addTriggerHandler: St, bodyContains: le, canAccessLocalStorage: B, findThisElement: Se, filterValues: hn, swap: $e, hasAttribute: s, getAttributeValue: te, getClosestAttributeValue: re, getClosestMatch: o, getExpressionVars: En, getHeaders: fn, getInputValues: cn, getInternalData: ie, getSwapSpecification: gn, getTriggerSpecs: st, getTarget: Ee, makeFragment: P, mergeObjects: ce, makeSettleInfo: xn, oobSwap: He, querySelectorExt: ae, settleImmediately: Kt, shouldCancel: ht, triggerEvent: he, triggerErrorEvent: fe, withExtensions: Ft }; const r = ["get", "post", "put", "delete", "patch"]; const H = r.map(function (e) { return "[hx-" + e + "], [data-hx-" + e + "]" }).join(", "); function d(e) { if (e == undefined) { return undefined } let t = NaN; if (e.slice(-2) == "ms") { t = parseFloat(e.slice(0, -2)) } else if (e.slice(-1) == "s") { t = parseFloat(e.slice(0, -1)) * 1e3 } else if (e.slice(-1) == "m") { t = parseFloat(e.slice(0, -1)) * 1e3 * 60 } else { t = parseFloat(e) } return isNaN(t) ? undefined : t } function ee(e, t) { return e instanceof Element && e.getAttribute(t) } function s(e, t) { return !!e.hasAttribute && (e.hasAttribute(t) || e.hasAttribute("data-" + t)) } function te(e, t) { return ee(e, t) || ee(e, "data-" + t) } function c(e) { const t = e.parentElement; if (!t && e.parentNode instanceof ShadowRoot) return e.parentNode; return t } function ne() { return document } function m(e, t) { return e.getRootNode ? e.getRootNode({ composed: t }) : ne() } function o(e, t) { while (e && !t(e)) { e = c(e) } return e || null } function i(e, t, n) { const r = te(t, n); const o = te(t, "hx-disinherit"); var i = te(t, "hx-inherit"); if (e !== t) { if (Q.config.disableInheritance) { if (i && (i === "*" || i.split(" ").indexOf(n) >= 0)) { return r } else { return null } } if (o && (o === "*" || o.split(" ").indexOf(n) >= 0)) { return "unset" } } return r } function re(t, n) { let r = null; o(t, function (e) { return !!(r = i(t, ue(e), n)) }); if (r !== "unset") { return r } } function h(e, t) { const n = e instanceof Element && (e.matches || e.matchesSelector || e.msMatchesSelector || e.mozMatchesSelector || e.webkitMatchesSelector || e.oMatchesSelector); return !!n && n.call(e, t) } function T(e) { const t = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i; const n = t.exec(e); if (n) { return n[1].toLowerCase() } else { return "" } } function q(e) { const t = new DOMParser; return t.parseFromString(e, "text/html") } function L(e, t) { while (t.childNodes.length > 0) { e.append(t.childNodes[0]) } } function A(e) { const t = ne().createElement("script"); se(e.attributes, function (e) { t.setAttribute(e.name, e.value) }); t.textContent = e.textContent; t.async = false; if (Q.config.inlineScriptNonce) { t.nonce = Q.config.inlineScriptNonce } return t } function N(e) { return e.matches("script") && (e.type === "text/javascript" || e.type === "module" || e.type === "") } function I(e) { Array.from(e.querySelectorAll("script")).forEach(e => { if (N(e)) { const t = A(e); const n = e.parentNode; try { n.insertBefore(t, e) } catch (e) { O(e) } finally { e.remove() } } }) } function P(e) { const t = e.replace(/]*)?>[\s\S]*?<\/head>/i, ""); const n = T(t); let r; if (n === "html") { r = new DocumentFragment; const i = q(e); L(r, i.body); r.title = i.title } else if (n === "body") { r = new DocumentFragment; const i = q(t); L(r, i.body); r.title = i.title } else { const i = q('"); r = i.querySelector("template").content; r.title = i.title; var o = r.querySelector("title"); if (o && o.parentNode === r) { o.remove(); r.title = o.innerText } } if (r) { if (Q.config.allowScriptTags) { I(r) } else { r.querySelectorAll("script").forEach(e => e.remove()) } } return r } function oe(e) { if (e) { e() } } function t(e, t) { return Object.prototype.toString.call(e) === "[object " + t + "]" } function k(e) { return typeof e === "function" } function D(e) { return t(e, "Object") } function ie(e) { const t = "htmx-internal-data"; let n = e[t]; if (!n) { n = e[t] = {} } return n } function M(t) { const n = []; if (t) { for (let e = 0; e < t.length; e++) { n.push(t[e]) } } return n } function se(t, n) { if (t) { for (let e = 0; e < t.length; e++) { n(t[e]) } } } function X(e) { const t = e.getBoundingClientRect(); const n = t.top; const r = t.bottom; return n < window.innerHeight && r >= 0 } function le(e) { return e.getRootNode({ composed: true }) === document } function F(e) { return e.trim().split(/\s+/) } function ce(e, t) { for (const n in t) { if (t.hasOwnProperty(n)) { e[n] = t[n] } } return e } function S(e) { try { return JSON.parse(e) } catch (e) { O(e); return null } } function B() { const e = "htmx:localStorageTest"; try { localStorage.setItem(e, e); localStorage.removeItem(e); return true } catch (e) { return false } } function U(t) { try { const e = new URL(t); if (e) { t = e.pathname + e.search } if (!/^\/$/.test(t)) { t = t.replace(/\/+$/, "") } return t } catch (e) { return t } } function e(e) { return vn(ne().body, function () { return eval(e) }) } function j(t) { const e = Q.on("htmx:load", function (e) { t(e.detail.elt) }); return e } function V() { Q.logger = function (e, t, n) { if (console) { console.log(t, e, n) } } } function _() { Q.logger = null } function u(e, t) { if (typeof e !== "string") { return e.querySelector(t) } else { return u(ne(), e) } } function x(e, t) { if (typeof e !== "string") { return e.querySelectorAll(t) } else { return x(ne(), e) } } function E() { return window } function z(e, t) { e = y(e); if (t) { E().setTimeout(function () { z(e); e = null }, t) } else { c(e).removeChild(e) } } function ue(e) { return e instanceof Element ? e : null } function $(e) { return e instanceof HTMLElement ? e : null } function J(e) { return typeof e === "string" ? e : null } function f(e) { return e instanceof Element || e instanceof Document || e instanceof DocumentFragment ? e : null } function K(e, t, n) { e = ue(y(e)); if (!e) { return } if (n) { E().setTimeout(function () { K(e, t); e = null }, n) } else { e.classList && e.classList.add(t) } } function G(e, t, n) { let r = ue(y(e)); if (!r) { return } if (n) { E().setTimeout(function () { G(r, t); r = null }, n) } else { if (r.classList) { r.classList.remove(t); if (r.classList.length === 0) { r.removeAttribute("class") } } } } function W(e, t) { e = y(e); e.classList.toggle(t) } function Z(e, t) { e = y(e); se(e.parentElement.children, function (e) { G(e, t) }); K(ue(e), t) } function g(e, t) { e = ue(y(e)); if (e && e.closest) { return e.closest(t) } else { do { if (e == null || h(e, t)) { return e } } while (e = e && ue(c(e))); return null } } function l(e, t) { return e.substring(0, t.length) === t } function Y(e, t) { return e.substring(e.length - t.length) === t } function ge(e) { const t = e.trim(); if (l(t, "<") && Y(t, "/>")) { return t.substring(1, t.length - 2) } else { return t } } function p(t, r, n) { if (r.indexOf("global ") === 0) { return p(t, r.slice(7), true) } t = y(t); const o = []; { let t = 0; let n = 0; for (let e = 0; e < r.length; e++) { const l = r[e]; if (l === "," && t === 0) { o.push(r.substring(n, e)); n = e + 1; continue } if (l === "<") { t++ } else if (l === "/" && e < r.length - 1 && r[e + 1] === ">") { t-- } } if (n < r.length) { o.push(r.substring(n)) } } const i = []; const s = []; while (o.length > 0) { const r = ge(o.shift()); let e; if (r.indexOf("closest ") === 0) { e = g(ue(t), ge(r.substr(8))) } else if (r.indexOf("find ") === 0) { e = u(f(t), ge(r.substr(5))) } else if (r === "next" || r === "nextElementSibling") { e = ue(t).nextElementSibling } else if (r.indexOf("next ") === 0) { e = pe(t, ge(r.substr(5)), !!n) } else if (r === "previous" || r === "previousElementSibling") { e = ue(t).previousElementSibling } else if (r.indexOf("previous ") === 0) { e = me(t, ge(r.substr(9)), !!n) } else if (r === "document") { e = document } else if (r === "window") { e = window } else if (r === "body") { e = document.body } else if (r === "root") { e = m(t, !!n) } else if (r === "host") { e = t.getRootNode().host } else { s.push(r) } if (e) { i.push(e) } } if (s.length > 0) { const e = s.join(","); const c = f(m(t, !!n)); i.push(...M(c.querySelectorAll(e))) } return i } var pe = function (t, e, n) { const r = f(m(t, n)).querySelectorAll(e); for (let e = 0; e < r.length; e++) { const o = r[e]; if (o.compareDocumentPosition(t) === Node.DOCUMENT_POSITION_PRECEDING) { return o } } }; var me = function (t, e, n) { const r = f(m(t, n)).querySelectorAll(e); for (let e = r.length - 1; e >= 0; e--) { const o = r[e]; if (o.compareDocumentPosition(t) === Node.DOCUMENT_POSITION_FOLLOWING) { return o } } }; function ae(e, t) { if (typeof e !== "string") { return p(e, t)[0] } else { return p(ne().body, e)[0] } } function y(e, t) { if (typeof e === "string") { return u(f(t) || document, e) } else { return e } } function xe(e, t, n, r) { if (k(t)) { return { target: ne().body, event: J(e), listener: t, options: n } } else { return { target: y(e), event: J(t), listener: n, options: r } } } function ye(t, n, r, o) { Vn(function () { const e = xe(t, n, r, o); e.target.addEventListener(e.event, e.listener, e.options) }); const e = k(n); return e ? n : r } function be(t, n, r) { Vn(function () { const e = xe(t, n, r); e.target.removeEventListener(e.event, e.listener) }); return k(n) ? n : r } const ve = ne().createElement("output"); function we(e, t) { const n = re(e, t); if (n) { if (n === "this") { return [Se(e, t)] } else { const r = p(e, n); if (r.length === 0) { O('The selector "' + n + '" on ' + t + " returned no matches!"); return [ve] } else { return r } } } } function Se(e, t) { return ue(o(e, function (e) { return te(ue(e), t) != null })) } function Ee(e) { const t = re(e, "hx-target"); if (t) { if (t === "this") { return Se(e, "hx-target") } else { return ae(e, t) } } else { const n = ie(e); if (n.boosted) { return ne().body } else { return e } } } function Ce(t) { const n = Q.config.attributesToSettle; for (let e = 0; e < n.length; e++) { if (t === n[e]) { return true } } return false } function Oe(t, n) { se(t.attributes, function (e) { if (!n.hasAttribute(e.name) && Ce(e.name)) { t.removeAttribute(e.name) } }); se(n.attributes, function (e) { if (Ce(e.name)) { t.setAttribute(e.name, e.value) } }) } function Re(t, e) { const n = Un(e); for (let e = 0; e < n.length; e++) { const r = n[e]; try { if (r.isInlineSwap(t)) { return true } } catch (e) { O(e) } } return t === "outerHTML" } function He(e, o, i, t) { t = t || ne(); let n = "#" + ee(o, "id"); let s = "outerHTML"; if (e === "true") { } else if (e.indexOf(":") > 0) { s = e.substring(0, e.indexOf(":")); n = e.substring(e.indexOf(":") + 1) } else { s = e } o.removeAttribute("hx-swap-oob"); o.removeAttribute("data-hx-swap-oob"); const r = p(t, n, false); if (r) { se(r, function (e) { let t; const n = o.cloneNode(true); t = ne().createDocumentFragment(); t.appendChild(n); if (!Re(s, e)) { t = f(n) } const r = { shouldSwap: true, target: e, fragment: t }; if (!he(e, "htmx:oobBeforeSwap", r)) return; e = r.target; if (r.shouldSwap) { qe(t); _e(s, e, e, t, i); Te() } se(i.elts, function (e) { he(e, "htmx:oobAfterSwap", r) }) }); o.parentNode.removeChild(o) } else { o.parentNode.removeChild(o); fe(ne().body, "htmx:oobErrorNoTarget", { content: o }) } return e } function Te() { const e = u("#--htmx-preserve-pantry--"); if (e) { for (const t of [...e.children]) { const n = u("#" + t.id); n.parentNode.moveBefore(t, n); n.remove() } e.remove() } } function qe(e) { se(x(e, "[hx-preserve], [data-hx-preserve]"), function (e) { const t = te(e, "id"); const n = ne().getElementById(t); if (n != null) { if (e.moveBefore) { let e = u("#--htmx-preserve-pantry--"); if (e == null) { ne().body.insertAdjacentHTML("afterend", "
"); e = u("#--htmx-preserve-pantry--") } e.moveBefore(n, null) } else { e.parentNode.replaceChild(n, e) } } }) } function Le(l, e, c) { se(e.querySelectorAll("[id]"), function (t) { const n = ee(t, "id"); if (n && n.length > 0) { const r = n.replace("'", "\\'"); const o = t.tagName.replace(":", "\\:"); const e = f(l); const i = e && e.querySelector(o + "[id='" + r + "']"); if (i && i !== e) { const s = t.cloneNode(); Oe(t, i); c.tasks.push(function () { Oe(t, s) }) } } }) } function Ae(e) { return function () { G(e, Q.config.addedClass); kt(ue(e)); Ne(f(e)); he(e, "htmx:load") } } function Ne(e) { const t = "[autofocus]"; const n = $(h(e, t) ? e : e.querySelector(t)); if (n != null) { n.focus() } } function a(e, t, n, r) { Le(e, n, r); while (n.childNodes.length > 0) { const o = n.firstChild; K(ue(o), Q.config.addedClass); e.insertBefore(o, t); if (o.nodeType !== Node.TEXT_NODE && o.nodeType !== Node.COMMENT_NODE) { r.tasks.push(Ae(o)) } } } function Ie(e, t) { let n = 0; while (n < e.length) { t = (t << 5) - t + e.charCodeAt(n++) | 0 } return t } function Pe(t) { let n = 0; if (t.attributes) { for (let e = 0; e < t.attributes.length; e++) { const r = t.attributes[e]; if (r.value) { n = Ie(r.name, n); n = Ie(r.value, n) } } } return n } function ke(t) { const n = ie(t); if (n.onHandlers) { for (let e = 0; e < n.onHandlers.length; e++) { const r = n.onHandlers[e]; be(t, r.event, r.listener) } delete n.onHandlers } } function De(e) { const t = ie(e); if (t.timeout) { clearTimeout(t.timeout) } if (t.listenerInfos) { se(t.listenerInfos, function (e) { if (e.on) { be(e.on, e.trigger, e.listener) } }) } ke(e); se(Object.keys(t), function (e) { if (e !== "firstInitCompleted") delete t[e] }) } function b(e) { he(e, "htmx:beforeCleanupElement"); De(e); if (e.children) { se(e.children, function (e) { b(e) }) } } function Me(t, e, n) { if (t instanceof Element && t.tagName === "BODY") { return Ve(t, e, n) } let r; const o = t.previousSibling; const i = c(t); if (!i) { return } a(i, t, e, n); if (o == null) { r = i.firstChild } else { r = o.nextSibling } n.elts = n.elts.filter(function (e) { return e !== t }); while (r && r !== t) { if (r instanceof Element) { n.elts.push(r) } r = r.nextSibling } b(t); if (t instanceof Element) { t.remove() } else { t.parentNode.removeChild(t) } } function Xe(e, t, n) { return a(e, e.firstChild, t, n) } function Fe(e, t, n) { return a(c(e), e, t, n) } function Be(e, t, n) { return a(e, null, t, n) } function Ue(e, t, n) { return a(c(e), e.nextSibling, t, n) } function je(e) { b(e); const t = c(e); if (t) { return t.removeChild(e) } } function Ve(e, t, n) { const r = e.firstChild; a(e, r, t, n); if (r) { while (r.nextSibling) { b(r.nextSibling); e.removeChild(r.nextSibling) } b(r); e.removeChild(r) } } function _e(t, e, n, r, o) { switch (t) { case "none": return; case "outerHTML": Me(n, r, o); return; case "afterbegin": Xe(n, r, o); return; case "beforebegin": Fe(n, r, o); return; case "beforeend": Be(n, r, o); return; case "afterend": Ue(n, r, o); return; case "delete": je(n); return; default: var i = Un(e); for (let e = 0; e < i.length; e++) { const s = i[e]; try { const l = s.handleSwap(t, n, r, o); if (l) { if (Array.isArray(l)) { for (let e = 0; e < l.length; e++) { const c = l[e]; if (c.nodeType !== Node.TEXT_NODE && c.nodeType !== Node.COMMENT_NODE) { o.tasks.push(Ae(c)) } } } return } } catch (e) { O(e) } } if (t === "innerHTML") { Ve(n, r, o) } else { _e(Q.config.defaultSwapStyle, e, n, r, o) } } } function ze(e, n, r) { var t = x(e, "[hx-swap-oob], [data-hx-swap-oob]"); se(t, function (e) { if (Q.config.allowNestedOobSwaps || e.parentElement === null) { const t = te(e, "hx-swap-oob"); if (t != null) { He(t, e, n, r) } } else { e.removeAttribute("hx-swap-oob"); e.removeAttribute("data-hx-swap-oob") } }); return t.length > 0 } function $e(e, t, r, o) { if (!o) { o = {} } e = y(e); const i = o.contextElement ? m(o.contextElement, false) : ne(); const n = document.activeElement; let s = {}; try { s = { elt: n, start: n ? n.selectionStart : null, end: n ? n.selectionEnd : null } } catch (e) { } const l = xn(e); if (r.swapStyle === "textContent") { e.textContent = t } else { let n = P(t); l.title = n.title; if (o.selectOOB) { const u = o.selectOOB.split(","); for (let t = 0; t < u.length; t++) { const a = u[t].split(":", 2); let e = a[0].trim(); if (e.indexOf("#") === 0) { e = e.substring(1) } const f = a[1] || "true"; const h = n.querySelector("#" + e); if (h) { He(f, h, l, i) } } } ze(n, l, i); se(x(n, "template"), function (e) { if (e.content && ze(e.content, l, i)) { e.remove() } }); if (o.select) { const d = ne().createDocumentFragment(); se(n.querySelectorAll(o.select), function (e) { d.appendChild(e) }); n = d } qe(n); _e(r.swapStyle, o.contextElement, e, n, l); Te() } if (s.elt && !le(s.elt) && ee(s.elt, "id")) { const g = document.getElementById(ee(s.elt, "id")); const p = { preventScroll: r.focusScroll !== undefined ? !r.focusScroll : !Q.config.defaultFocusScroll }; if (g) { if (s.start && g.setSelectionRange) { try { g.setSelectionRange(s.start, s.end) } catch (e) { } } g.focus(p) } } e.classList.remove(Q.config.swappingClass); se(l.elts, function (e) { if (e.classList) { e.classList.add(Q.config.settlingClass) } he(e, "htmx:afterSwap", o.eventInfo) }); if (o.afterSwapCallback) { o.afterSwapCallback() } if (!r.ignoreTitle) { kn(l.title) } const c = function () { se(l.tasks, function (e) { e.call() }); se(l.elts, function (e) { if (e.classList) { e.classList.remove(Q.config.settlingClass) } he(e, "htmx:afterSettle", o.eventInfo) }); if (o.anchor) { const e = ue(y("#" + o.anchor)); if (e) { e.scrollIntoView({ block: "start", behavior: "auto" }) } } yn(l.elts, r); if (o.afterSettleCallback) { o.afterSettleCallback() } }; if (r.settleDelay > 0) { E().setTimeout(c, r.settleDelay) } else { c() } } function Je(e, t, n) { const r = e.getResponseHeader(t); if (r.indexOf("{") === 0) { const o = S(r); for (const i in o) { if (o.hasOwnProperty(i)) { let e = o[i]; if (D(e)) { n = e.target !== undefined ? e.target : n } else { e = { value: e } } he(n, i, e) } } } else { const s = r.split(","); for (let e = 0; e < s.length; e++) { he(n, s[e].trim(), []) } } } const Ke = /\s/; const v = /[\s,]/; const Ge = /[_$a-zA-Z]/; const We = /[_$a-zA-Z0-9]/; const Ze = ['"', "'", "/"]; const w = /[^\s]/; const Ye = /[{(]/; const Qe = /[})]/; function et(e) { const t = []; let n = 0; while (n < e.length) { if (Ge.exec(e.charAt(n))) { var r = n; while (We.exec(e.charAt(n + 1))) { n++ } t.push(e.substring(r, n + 1)) } else if (Ze.indexOf(e.charAt(n)) !== -1) { const o = e.charAt(n); var r = n; n++; while (n < e.length && e.charAt(n) !== o) { if (e.charAt(n) === "\\") { n++ } n++ } t.push(e.substring(r, n + 1)) } else { const i = e.charAt(n); t.push(i) } n++ } return t } function tt(e, t, n) { return Ge.exec(e.charAt(0)) && e !== "true" && e !== "false" && e !== "this" && e !== n && t !== "." } function nt(r, o, i) { if (o[0] === "[") { o.shift(); let e = 1; let t = " return (function(" + i + "){ return ("; let n = null; while (o.length > 0) { const s = o[0]; if (s === "]") { e--; if (e === 0) { if (n === null) { t = t + "true" } o.shift(); t += ")})"; try { const l = vn(r, function () { return Function(t)() }, function () { return true }); l.source = t; return l } catch (e) { fe(ne().body, "htmx:syntax:error", { error: e, source: t }); return null } } } else if (s === "[") { e++ } if (tt(s, n, i)) { t += "((" + i + "." + s + ") ? (" + i + "." + s + ") : (window." + s + "))" } else { t = t + s } n = o.shift() } } } function C(e, t) { let n = ""; while (e.length > 0 && !t.test(e[0])) { n += e.shift() } return n } function rt(e) { let t; if (e.length > 0 && Ye.test(e[0])) { e.shift(); t = C(e, Qe).trim(); e.shift() } else { t = C(e, v) } return t } const ot = "input, textarea, select"; function it(e, t, n) { const r = []; const o = et(t); do { C(o, w); const l = o.length; const c = C(o, /[,\[\s]/); if (c !== "") { if (c === "every") { const u = { trigger: "every" }; C(o, w); u.pollInterval = d(C(o, /[,\[\s]/)); C(o, w); var i = nt(e, o, "event"); if (i) { u.eventFilter = i } r.push(u) } else { const a = { trigger: c }; var i = nt(e, o, "event"); if (i) { a.eventFilter = i } C(o, w); while (o.length > 0 && o[0] !== ",") { const f = o.shift(); if (f === "changed") { a.changed = true } else if (f === "once") { a.once = true } else if (f === "consume") { a.consume = true } else if (f === "delay" && o[0] === ":") { o.shift(); a.delay = d(C(o, v)) } else if (f === "from" && o[0] === ":") { o.shift(); if (Ye.test(o[0])) { var s = rt(o) } else { var s = C(o, v); if (s === "closest" || s === "find" || s === "next" || s === "previous") { o.shift(); const h = rt(o); if (h.length > 0) { s += " " + h } } } a.from = s } else if (f === "target" && o[0] === ":") { o.shift(); a.target = rt(o) } else if (f === "throttle" && o[0] === ":") { o.shift(); a.throttle = d(C(o, v)) } else if (f === "queue" && o[0] === ":") { o.shift(); a.queue = C(o, v) } else if (f === "root" && o[0] === ":") { o.shift(); a[f] = rt(o) } else if (f === "threshold" && o[0] === ":") { o.shift(); a[f] = C(o, v) } else { fe(e, "htmx:syntax:error", { token: o.shift() }) } C(o, w) } r.push(a) } } if (o.length === l) { fe(e, "htmx:syntax:error", { token: o.shift() }) } C(o, w) } while (o[0] === "," && o.shift()); if (n) { n[t] = r } return r } function st(e) { const t = te(e, "hx-trigger"); let n = []; if (t) { const r = Q.config.triggerSpecsCache; n = r && r[t] || it(e, t, r) } if (n.length > 0) { return n } 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, ot)) { return [{ trigger: "change" }] } else { return [{ trigger: "click" }] } } function lt(e) { ie(e).cancelled = true } function ct(e, t, n) { const r = ie(e); r.timeout = E().setTimeout(function () { if (le(e) && r.cancelled !== true) { if (!gt(n, e, Mt("hx:poll:trigger", { triggerSpec: n, target: e }))) { t(e) } ct(e, t, n) } }, n.pollInterval) } function ut(e) { return location.hostname === e.hostname && ee(e, "href") && ee(e, "href").indexOf("#") !== 0 } function at(e) { return g(e, Q.config.disableSelector) } function ft(t, n, e) { if (t instanceof HTMLAnchorElement && ut(t) && (t.target === "" || t.target === "_self") || t.tagName === "FORM" && String(ee(t, "method")).toLowerCase() !== "dialog") { n.boosted = true; let r, o; if (t.tagName === "A") { r = "get"; o = ee(t, "href") } else { const i = ee(t, "method"); r = i ? i.toLowerCase() : "get"; o = ee(t, "action"); if (o == null || o === "") { o = ne().location.href } if (r === "get" && o.includes("?")) { o = o.replace(/\?[^#]+/, "") } } e.forEach(function (e) { pt(t, function (e, t) { const n = ue(e); if (at(n)) { b(n); return } de(r, o, n, t) }, n, e, true) }) } } function ht(e, t) { const n = ue(t); if (!n) { return false } if (e.type === "submit" || e.type === "click") { if (n.tagName === "FORM") { return true } if (h(n, 'input[type="submit"], button') && (h(n, "[form]") || g(n, "form") !== null)) { return true } if (n instanceof HTMLAnchorElement && n.href && (n.getAttribute("href") === "#" || n.getAttribute("href").indexOf("#") !== 0)) { return true } } return false } function dt(e, t) { return ie(e).boosted && e instanceof HTMLAnchorElement && t.type === "click" && (t.ctrlKey || t.metaKey) } function gt(e, t, n) { const r = e.eventFilter; if (r) { try { return r.call(t, n) !== true } catch (e) { const o = r.source; fe(ne().body, "htmx:eventFilter:error", { error: e, source: o }); return true } } return false } function pt(l, c, e, u, a) { const f = ie(l); let t; if (u.from) { t = p(l, u.from) } else { t = [l] } if (u.changed) { if (!("lastValue" in f)) { f.lastValue = new WeakMap } t.forEach(function (e) { if (!f.lastValue.has(u)) { f.lastValue.set(u, new WeakMap) } f.lastValue.get(u).set(e, e.value) }) } se(t, function (i) { const s = function (e) { if (!le(l)) { i.removeEventListener(u.trigger, s); return } if (dt(l, e)) { return } if (a || ht(e, l)) { e.preventDefault() } if (gt(u, l, e)) { return } const t = ie(e); t.triggerSpec = u; if (t.handledFor == null) { t.handledFor = [] } if (t.handledFor.indexOf(l) < 0) { t.handledFor.push(l); if (u.consume) { e.stopPropagation() } if (u.target && e.target) { if (!h(ue(e.target), u.target)) { return } } if (u.once) { if (f.triggeredOnce) { return } else { f.triggeredOnce = true } } if (u.changed) { const n = event.target; const r = n.value; const o = f.lastValue.get(u); if (o.has(n) && o.get(n) === r) { return } o.set(n, r) } if (f.delayed) { clearTimeout(f.delayed) } if (f.throttle) { return } if (u.throttle > 0) { if (!f.throttle) { he(l, "htmx:trigger"); c(l, e); f.throttle = E().setTimeout(function () { f.throttle = null }, u.throttle) } } else if (u.delay > 0) { f.delayed = E().setTimeout(function () { he(l, "htmx:trigger"); c(l, e) }, u.delay) } else { he(l, "htmx:trigger"); c(l, e) } } }; if (e.listenerInfos == null) { e.listenerInfos = [] } e.listenerInfos.push({ trigger: u.trigger, listener: s, on: i }); i.addEventListener(u.trigger, s) }) } let mt = false; let xt = null; function yt() { if (!xt) { xt = function () { mt = true }; window.addEventListener("scroll", xt); window.addEventListener("resize", xt); setInterval(function () { if (mt) { mt = false; se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"), function (e) { bt(e) }) } }, 200) } } function bt(e) { if (!s(e, "data-hx-revealed") && X(e)) { e.setAttribute("data-hx-revealed", "true"); const t = ie(e); if (t.initHash) { he(e, "revealed") } else { e.addEventListener("htmx:afterProcessNode", function () { he(e, "revealed") }, { once: true }) } } } function vt(e, t, n, r) { const o = function () { if (!n.loaded) { n.loaded = true; he(e, "htmx:trigger"); t(e) } }; if (r > 0) { E().setTimeout(o, r) } else { o() } } function wt(t, n, e) { let i = false; se(r, function (r) { if (s(t, "hx-" + r)) { const o = te(t, "hx-" + r); i = true; n.path = o; n.verb = r; e.forEach(function (e) { St(t, e, n, function (e, t) { const n = ue(e); if (g(n, Q.config.disableSelector)) { b(n); return } de(r, o, n, t) }) }) } }); return i } function St(r, e, t, n) { if (e.trigger === "revealed") { yt(); pt(r, n, t, e); bt(ue(r)) } else if (e.trigger === "intersect") { const o = {}; if (e.root) { o.root = ae(r, e.root) } if (e.threshold) { o.threshold = parseFloat(e.threshold) } const i = new IntersectionObserver(function (t) { for (let e = 0; e < t.length; e++) { const n = t[e]; if (n.isIntersecting) { he(r, "intersect"); break } } }, o); i.observe(ue(r)); pt(ue(r), n, t, e) } else if (!t.firstInitCompleted && e.trigger === "load") { if (!gt(e, r, Mt("load", { elt: r }))) { vt(ue(r), n, t, e.delay) } } else if (e.pollInterval > 0) { t.polling = true; ct(ue(r), n, e) } else { pt(r, n, t, e) } } function Et(e) { const t = ue(e); if (!t) { return false } const n = t.attributes; for (let e = 0; e < n.length; e++) { const r = n[e].name; if (l(r, "hx-on:") || l(r, "data-hx-on:") || l(r, "hx-on-") || l(r, "data-hx-on-")) { return true } } return false } const Ct = (new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' + ' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]'); function Ot(e, t) { if (Et(e)) { t.push(ue(e)) } const n = Ct.evaluate(e); let r = null; while (r = n.iterateNext()) t.push(ue(r)) } function Rt(e) { const t = []; if (e instanceof DocumentFragment) { for (const n of e.childNodes) { Ot(n, t) } } else { Ot(e, t) } return t } function Ht(e) { if (e.querySelectorAll) { const n = ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]"; const r = []; for (const i in Mn) { const s = Mn[i]; if (s.getSelectors) { var t = s.getSelectors(); if (t) { r.push(t) } } } const o = e.querySelectorAll(H + n + ", form, [type='submit']," + " [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]" + r.flat().map(e => ", " + e).join("")); return o } else { return [] } } function Tt(e) { const t = g(ue(e.target), "button, input[type='submit']"); const n = Lt(e); if (n) { n.lastButtonClicked = t } } function qt(e) { const t = Lt(e); if (t) { t.lastButtonClicked = null } } function Lt(e) { const t = g(ue(e.target), "button, input[type='submit']"); if (!t) { return } const n = y("#" + ee(t, "form"), t.getRootNode()) || g(t, "form"); if (!n) { return } return ie(n) } function At(e) { e.addEventListener("click", Tt); e.addEventListener("focusin", Tt); e.addEventListener("focusout", qt) } function Nt(t, e, n) { const r = ie(t); if (!Array.isArray(r.onHandlers)) { r.onHandlers = [] } let o; const i = function (e) { vn(t, function () { if (at(t)) { return } if (!o) { o = new Function("event", n) } o.call(t, e) }) }; t.addEventListener(e, i); r.onHandlers.push({ event: e, listener: i }) } function It(t) { ke(t); for (let e = 0; e < t.attributes.length; e++) { const n = t.attributes[e].name; const r = t.attributes[e].value; if (l(n, "hx-on") || l(n, "data-hx-on")) { const o = n.indexOf("-on") + 3; const i = n.slice(o, o + 1); if (i === "-" || i === ":") { let e = n.slice(o + 1); if (l(e, ":")) { e = "htmx" + e } else if (l(e, "-")) { e = "htmx:" + e.slice(1) } else if (l(e, "htmx-")) { e = "htmx:" + e.slice(5) } Nt(t, e, r) } } } } function Pt(t) { if (g(t, Q.config.disableSelector)) { b(t); return } const n = ie(t); const e = Pe(t); if (n.initHash !== e) { De(t); n.initHash = e; he(t, "htmx:beforeProcessNode"); const r = st(t); const o = wt(t, n, r); if (!o) { if (re(t, "hx-boost") === "true") { ft(t, n, r) } else if (s(t, "hx-trigger")) { r.forEach(function (e) { St(t, e, n, function () { }) }) } } if (t.tagName === "FORM" || ee(t, "type") === "submit" && s(t, "form")) { At(t) } n.firstInitCompleted = true; he(t, "htmx:afterProcessNode") } } function kt(e) { e = y(e); if (g(e, Q.config.disableSelector)) { b(e); return } Pt(e); se(Ht(e), function (e) { Pt(e) }); se(Rt(e), It) } function Dt(e) { return e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase() } function Mt(e, t) { let n; if (window.CustomEvent && typeof window.CustomEvent === "function") { n = new CustomEvent(e, { bubbles: true, cancelable: true, composed: true, detail: t }) } else { n = ne().createEvent("CustomEvent"); n.initCustomEvent(e, true, true, t) } return n } function fe(e, t, n) { he(e, t, ce({ error: t }, n)) } function Xt(e) { return e === "htmx:afterProcessNode" } function Ft(e, t) { se(Un(e), function (e) { try { t(e) } catch (e) { O(e) } }) } function O(e) { if (console.error) { console.error(e) } else if (console.log) { console.log("ERROR: ", e) } } function he(e, t, n) { e = y(e); if (n == null) { n = {} } n.elt = e; const r = Mt(t, n); if (Q.logger && !Xt(t)) { Q.logger(e, t, n) } if (n.error) { O(n.error); he(e, "htmx:error", { errorInfo: n }) } let o = e.dispatchEvent(r); const i = Dt(t); if (o && i !== t) { const s = Mt(i, r.detail); o = o && e.dispatchEvent(s) } Ft(ue(e), function (e) { o = o && (e.onEvent(t, r) !== false && !r.defaultPrevented) }); return o } let Bt = location.pathname + location.search; function Ut() { const e = ne().querySelector("[hx-history-elt],[data-hx-history-elt]"); return e || ne().body } function jt(t, e) { if (!B()) { return } const n = _t(e); const r = ne().title; const o = window.scrollY; if (Q.config.historyCacheSize <= 0) { localStorage.removeItem("htmx-history-cache"); return } t = U(t); const i = S(localStorage.getItem("htmx-history-cache")) || []; for (let e = 0; e < i.length; e++) { if (i[e].url === t) { i.splice(e, 1); break } } const s = { url: t, content: n, title: r, scroll: o }; he(ne().body, "htmx:historyItemCreated", { item: s, cache: i }); i.push(s); while (i.length > Q.config.historyCacheSize) { i.shift() } while (i.length > 0) { try { localStorage.setItem("htmx-history-cache", JSON.stringify(i)); break } catch (e) { fe(ne().body, "htmx:historyCacheError", { cause: e, cache: i }); i.shift() } } } function Vt(t) { if (!B()) { return null } t = U(t); const n = S(localStorage.getItem("htmx-history-cache")) || []; for (let e = 0; e < n.length; e++) { if (n[e].url === t) { return n[e] } } return null } function _t(e) { const t = Q.config.requestClass; const n = e.cloneNode(true); se(x(n, "." + t), function (e) { G(e, t) }); se(x(n, "[data-disabled-by-htmx]"), function (e) { e.removeAttribute("disabled") }); return n.innerHTML } function zt() { const e = Ut(); const t = Bt || location.pathname + location.search; let n; try { n = ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]') } catch (e) { n = ne().querySelector('[hx-history="false"],[data-hx-history="false"]') } if (!n) { he(ne().body, "htmx:beforeHistorySave", { path: t, historyElt: e }); jt(t, e) } if (Q.config.historyEnabled) history.replaceState({ htmx: true }, ne().title, window.location.href) } function $t(e) { if (Q.config.getCacheBusterParam) { e = e.replace(/org\.htmx\.cache-buster=[^&]*&?/, ""); if (Y(e, "&") || Y(e, "?")) { e = e.slice(0, -1) } } if (Q.config.historyEnabled) { history.pushState({ htmx: true }, "", e) } Bt = e } function Jt(e) { if (Q.config.historyEnabled) history.replaceState({ htmx: true }, "", e); Bt = e } function Kt(e) { se(e, function (e) { e.call(undefined) }) } function Gt(o) { const e = new XMLHttpRequest; const i = { path: o, xhr: e }; he(ne().body, "htmx:historyCacheMiss", i); e.open("GET", o, true); e.setRequestHeader("HX-Request", "true"); e.setRequestHeader("HX-History-Restore-Request", "true"); e.setRequestHeader("HX-Current-URL", ne().location.href); e.onload = function () { if (this.status >= 200 && this.status < 400) { he(ne().body, "htmx:historyCacheMissLoad", i); const e = P(this.response); const t = e.querySelector("[hx-history-elt],[data-hx-history-elt]") || e; const n = Ut(); const r = xn(n); kn(e.title); qe(e); Ve(n, t, r); Te(); Kt(r.tasks); Bt = o; he(ne().body, "htmx:historyRestore", { path: o, cacheMiss: true, serverResponse: this.response }) } else { fe(ne().body, "htmx:historyCacheMissLoadError", i) } }; e.send() } function Wt(e) { zt(); e = e || location.pathname + location.search; const t = Vt(e); if (t) { const n = P(t.content); const r = Ut(); const o = xn(r); kn(t.title); qe(n); Ve(r, n, o); Te(); Kt(o.tasks); E().setTimeout(function () { window.scrollTo(0, t.scroll) }, 0); Bt = e; he(ne().body, "htmx:historyRestore", { path: e, item: t }) } else { if (Q.config.refreshOnHistoryMiss) { window.location.reload(true) } else { Gt(e) } } } function Zt(e) { let t = we(e, "hx-indicator"); if (t == null) { t = [e] } se(t, function (e) { const t = ie(e); t.requestCount = (t.requestCount || 0) + 1; e.classList.add.call(e.classList, Q.config.requestClass) }); return t } function Yt(e) { let t = we(e, "hx-disabled-elt"); if (t == null) { t = [] } se(t, function (e) { const t = ie(e); t.requestCount = (t.requestCount || 0) + 1; e.setAttribute("disabled", ""); e.setAttribute("data-disabled-by-htmx", "") }); return t } function Qt(e, t) { se(e.concat(t), function (e) { const t = ie(e); t.requestCount = (t.requestCount || 1) - 1 }); se(e, function (e) { const t = ie(e); if (t.requestCount === 0) { e.classList.remove.call(e.classList, Q.config.requestClass) } }); se(t, function (e) { const t = ie(e); if (t.requestCount === 0) { e.removeAttribute("disabled"); e.removeAttribute("data-disabled-by-htmx") } }) } function en(t, n) { for (let e = 0; e < t.length; e++) { const r = t[e]; if (r.isSameNode(n)) { return true } } return false } function tn(e) { const t = e; if (t.name === "" || t.name == null || t.disabled || g(t, "fieldset[disabled]")) { return false } if (t.type === "button" || t.type === "submit" || t.tagName === "image" || t.tagName === "reset" || t.tagName === "file") { return false } if (t.type === "checkbox" || t.type === "radio") { return t.checked } return true } function nn(t, e, n) { if (t != null && e != null) { if (Array.isArray(e)) { e.forEach(function (e) { n.append(t, e) }) } else { n.append(t, e) } } } function rn(t, n, r) { if (t != null && n != null) { let e = r.getAll(t); if (Array.isArray(n)) { e = e.filter(e => n.indexOf(e) < 0) } else { e = e.filter(e => e !== n) } r.delete(t); se(e, e => r.append(t, e)) } } function on(t, n, r, o, i) { if (o == null || en(t, o)) { return } else { t.push(o) } if (tn(o)) { const s = ee(o, "name"); let e = o.value; if (o instanceof HTMLSelectElement && o.multiple) { e = M(o.querySelectorAll("option:checked")).map(function (e) { return e.value }) } if (o instanceof HTMLInputElement && o.files) { e = M(o.files) } nn(s, e, n); if (i) { sn(o, r) } } if (o instanceof HTMLFormElement) { se(o.elements, function (e) { if (t.indexOf(e) >= 0) { rn(e.name, e.value, n) } else { t.push(e) } if (i) { sn(e, r) } }); new FormData(o).forEach(function (e, t) { if (e instanceof File && e.name === "") { return } nn(t, e, n) }) } } function sn(e, t) { const n = e; if (n.willValidate) { he(n, "htmx:validation:validate"); if (!n.checkValidity()) { t.push({ elt: n, message: n.validationMessage, validity: n.validity }); he(n, "htmx:validation:failed", { message: n.validationMessage, validity: n.validity }) } } } function ln(n, e) { for (const t of e.keys()) { n.delete(t) } e.forEach(function (e, t) { n.append(t, e) }); return n } function cn(e, t) { const n = []; const r = new FormData; const o = new FormData; const i = []; const s = ie(e); if (s.lastButtonClicked && !le(s.lastButtonClicked)) { s.lastButtonClicked = null } let l = e instanceof HTMLFormElement && e.noValidate !== true || te(e, "hx-validate") === "true"; if (s.lastButtonClicked) { l = l && s.lastButtonClicked.formNoValidate !== true } if (t !== "get") { on(n, o, i, g(e, "form"), l) } on(n, r, i, e, l); if (s.lastButtonClicked || e.tagName === "BUTTON" || e.tagName === "INPUT" && ee(e, "type") === "submit") { const u = s.lastButtonClicked || e; const a = ee(u, "name"); nn(a, u.value, o) } const c = we(e, "hx-include"); se(c, function (e) { on(n, r, i, ue(e), l); if (!h(e, "form")) { se(f(e).querySelectorAll(ot), function (e) { on(n, r, i, e, l) }) } }); ln(r, o); return { errors: i, formData: r, values: An(r) } } function un(e, t, n) { if (e !== "") { e += "&" } if (String(n) === "[object Object]") { n = JSON.stringify(n) } const r = encodeURIComponent(n); e += encodeURIComponent(t) + "=" + r; return e } function an(e) { e = qn(e); let n = ""; e.forEach(function (e, t) { n = un(n, t, e) }); return n } function fn(e, t, n) { const r = { "HX-Request": "true", "HX-Trigger": ee(e, "id"), "HX-Trigger-Name": ee(e, "name"), "HX-Target": te(t, "id"), "HX-Current-URL": ne().location.href }; bn(e, "hx-headers", false, r); if (n !== undefined) { r["HX-Prompt"] = n } if (ie(e).boosted) { r["HX-Boosted"] = "true" } return r } function hn(n, e) { const t = re(e, "hx-params"); if (t) { if (t === "none") { return new FormData } else if (t === "*") { return n } else if (t.indexOf("not ") === 0) { se(t.slice(4).split(","), function (e) { e = e.trim(); n.delete(e) }); return n } else { const r = new FormData; se(t.split(","), function (t) { t = t.trim(); if (n.has(t)) { n.getAll(t).forEach(function (e) { r.append(t, e) }) } }); return r } } else { return n } } function dn(e) { return !!ee(e, "href") && ee(e, "href").indexOf("#") >= 0 } function gn(e, t) { const n = t || re(e, "hx-swap"); const r = { swapStyle: ie(e).boosted ? "innerHTML" : Q.config.defaultSwapStyle, swapDelay: Q.config.defaultSwapDelay, settleDelay: Q.config.defaultSettleDelay }; if (Q.config.scrollIntoViewOnBoost && ie(e).boosted && !dn(e)) { r.show = "top" } if (n) { const s = F(n); if (s.length > 0) { for (let e = 0; e < s.length; e++) { const l = s[e]; if (l.indexOf("swap:") === 0) { r.swapDelay = d(l.slice(5)) } else if (l.indexOf("settle:") === 0) { r.settleDelay = d(l.slice(7)) } else if (l.indexOf("transition:") === 0) { r.transition = l.slice(11) === "true" } else if (l.indexOf("ignoreTitle:") === 0) { r.ignoreTitle = l.slice(12) === "true" } else if (l.indexOf("scroll:") === 0) { const c = l.slice(7); var o = c.split(":"); const u = o.pop(); var i = o.length > 0 ? o.join(":") : null; r.scroll = u; r.scrollTarget = i } else if (l.indexOf("show:") === 0) { const a = l.slice(5); var o = a.split(":"); const f = o.pop(); var i = o.length > 0 ? o.join(":") : null; r.show = f; r.showTarget = i } else if (l.indexOf("focus-scroll:") === 0) { const h = l.slice("focus-scroll:".length); r.focusScroll = h == "true" } else if (e == 0) { r.swapStyle = l } else { O("Unknown modifier in hx-swap: " + l) } } } } return r } function pn(e) { return re(e, "hx-encoding") === "multipart/form-data" || h(e, "form") && ee(e, "enctype") === "multipart/form-data" } function mn(t, n, r) { let o = null; Ft(n, function (e) { if (o == null) { o = e.encodeParameters(t, r, n) } }); if (o != null) { return o } else { if (pn(n)) { return ln(new FormData, qn(r)) } else { return an(r) } } } function xn(e) { return { tasks: [], elts: [e] } } function yn(e, t) { const n = e[0]; const r = e[e.length - 1]; if (t.scroll) { var o = null; if (t.scrollTarget) { o = ue(ae(n, t.scrollTarget)) } if (t.scroll === "top" && (n || o)) { o = o || n; o.scrollTop = 0 } if (t.scroll === "bottom" && (r || o)) { o = o || r; o.scrollTop = o.scrollHeight } } if (t.show) { var o = null; if (t.showTarget) { let e = t.showTarget; if (t.showTarget === "window") { e = "body" } o = ue(ae(n, e)) } if (t.show === "top" && (n || o)) { o = o || n; o.scrollIntoView({ block: "start", behavior: Q.config.scrollBehavior }) } if (t.show === "bottom" && (r || o)) { o = o || r; o.scrollIntoView({ block: "end", behavior: Q.config.scrollBehavior }) } } } function bn(r, e, o, i) { if (i == null) { i = {} } if (r == null) { return i } const s = te(r, e); if (s) { let e = s.trim(); let t = o; if (e === "unset") { return null } if (e.indexOf("javascript:") === 0) { e = e.slice(11); t = true } else if (e.indexOf("js:") === 0) { e = e.slice(3); t = true } if (e.indexOf("{") !== 0) { e = "{" + e + "}" } let n; if (t) { n = vn(r, function () { return Function("return (" + e + ")")() }, {}) } else { n = S(e) } for (const l in n) { if (n.hasOwnProperty(l)) { if (i[l] == null) { i[l] = n[l] } } } } return bn(ue(c(r)), e, o, i) } function vn(e, t, n) { if (Q.config.allowEval) { return t() } else { fe(e, "htmx:evalDisallowedError"); return n } } function wn(e, t) { return bn(e, "hx-vars", true, t) } function Sn(e, t) { return bn(e, "hx-vals", false, t) } function En(e) { return ce(wn(e), Sn(e)) } function Cn(t, n, r) { if (r !== null) { try { t.setRequestHeader(n, r) } catch (e) { t.setRequestHeader(n, encodeURIComponent(r)); t.setRequestHeader(n + "-URI-AutoEncoded", "true") } } } function On(t) { if (t.responseURL && typeof URL !== "undefined") { try { const e = new URL(t.responseURL); return e.pathname + e.search } catch (e) { fe(ne().body, "htmx:badResponseUrl", { url: t.responseURL }) } } } function R(e, t) { return t.test(e.getAllResponseHeaders()) } function Rn(t, n, r) { t = t.toLowerCase(); if (r) { if (r instanceof Element || typeof r === "string") { return de(t, n, null, null, { targetOverride: y(r) || ve, returnPromise: true }) } else { let e = y(r.target); if (r.target && !e || r.source && !e && !y(r.source)) { e = ve } return de(t, n, y(r.source), r.event, { handler: r.handler, headers: r.headers, values: r.values, targetOverride: e, swapOverride: r.swap, select: r.select, returnPromise: true }) } } else { return de(t, n, null, null, { returnPromise: true }) } } function Hn(e) { const t = []; while (e) { t.push(e); e = e.parentElement } return t } function Tn(e, t, n) { let r; let o; if (typeof URL === "function") { o = new URL(t, document.location.href); const i = document.location.origin; r = i === o.origin } else { o = t; r = l(t, document.location.origin) } if (Q.config.selfRequestsOnly) { if (!r) { return false } } return he(e, "htmx:validateUrl", ce({ url: o, sameHost: r }, n)) } function qn(e) { if (e instanceof FormData) return e; const t = new FormData; for (const n in e) { if (e.hasOwnProperty(n)) { if (e[n] && typeof e[n].forEach === "function") { e[n].forEach(function (e) { t.append(n, e) }) } else if (typeof e[n] === "object" && !(e[n] instanceof Blob)) { t.append(n, JSON.stringify(e[n])) } else { t.append(n, e[n]) } } } return t } function Ln(r, o, e) { return new Proxy(e, { get: function (t, e) { if (typeof e === "number") return t[e]; if (e === "length") return t.length; if (e === "push") { return function (e) { t.push(e); r.append(o, e) } } if (typeof t[e] === "function") { return function () { t[e].apply(t, arguments); r.delete(o); t.forEach(function (e) { r.append(o, e) }) } } if (t[e] && t[e].length === 1) { return t[e][0] } else { return t[e] } }, set: function (e, t, n) { e[t] = n; r.delete(o); e.forEach(function (e) { r.append(o, e) }); return true } }) } function An(o) { return new Proxy(o, { get: function (e, t) { if (typeof t === "symbol") { const r = Reflect.get(e, t); if (typeof r === "function") { return function () { return r.apply(o, arguments) } } else { return r } } if (t === "toJSON") { return () => Object.fromEntries(o) } if (t in e) { if (typeof e[t] === "function") { return function () { return o[t].apply(o, arguments) } } else { return e[t] } } const n = o.getAll(t); if (n.length === 0) { return undefined } else if (n.length === 1) { return n[0] } else { return Ln(e, t, n) } }, set: function (t, n, e) { if (typeof n !== "string") { return false } t.delete(n); if (e && typeof e.forEach === "function") { e.forEach(function (e) { t.append(n, e) }) } else if (typeof e === "object" && !(e instanceof Blob)) { t.append(n, JSON.stringify(e)) } else { t.append(n, e) } return true }, deleteProperty: function (e, t) { if (typeof t === "string") { e.delete(t) } return true }, ownKeys: function (e) { return Reflect.ownKeys(Object.fromEntries(e)) }, getOwnPropertyDescriptor: function (e, t) { return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e), t) } }) } function de(t, n, r, o, i, D) { let s = null; let l = null; i = i != null ? i : {}; if (i.returnPromise && typeof Promise !== "undefined") { var e = new Promise(function (e, t) { s = e; l = t }) } if (r == null) { r = ne().body } const M = i.handler || Dn; const X = i.select || null; if (!le(r)) { oe(s); return e } const c = i.targetOverride || ue(Ee(r)); if (c == null || c == ve) { fe(r, "htmx:targetError", { target: te(r, "hx-target") }); oe(l); return e } let u = ie(r); const a = u.lastButtonClicked; if (a) { const L = ee(a, "formaction"); if (L != null) { n = L } const A = ee(a, "formmethod"); if (A != null) { if (A.toLowerCase() !== "dialog") { t = A } } } const f = re(r, "hx-confirm"); if (D === undefined) { const K = function (e) { return de(t, n, r, o, i, !!e) }; const G = { target: c, elt: r, path: n, verb: t, triggeringEvent: o, etc: i, issueRequest: K, question: f }; if (he(r, "htmx:confirm", G) === false) { oe(s); return e } } let h = r; let d = re(r, "hx-sync"); let g = null; let F = false; if (d) { const N = d.split(":"); const I = N[0].trim(); if (I === "this") { h = Se(r, "hx-sync") } else { h = ue(ae(r, I)) } d = (N[1] || "drop").trim(); u = ie(h); if (d === "drop" && u.xhr && u.abortable !== true) { oe(s); return e } else if (d === "abort") { if (u.xhr) { oe(s); return e } else { F = true } } else if (d === "replace") { he(h, "htmx:abort") } else if (d.indexOf("queue") === 0) { const W = d.split(" "); g = (W[1] || "last").trim() } } if (u.xhr) { if (u.abortable) { he(h, "htmx:abort") } else { if (g == null) { if (o) { const P = ie(o); if (P && P.triggerSpec && P.triggerSpec.queue) { g = P.triggerSpec.queue } } if (g == null) { g = "last" } } if (u.queuedRequests == null) { u.queuedRequests = [] } if (g === "first" && u.queuedRequests.length === 0) { u.queuedRequests.push(function () { de(t, n, r, o, i) }) } else if (g === "all") { u.queuedRequests.push(function () { de(t, n, r, o, i) }) } else if (g === "last") { u.queuedRequests = []; u.queuedRequests.push(function () { de(t, n, r, o, i) }) } oe(s); return e } } const p = new XMLHttpRequest; u.xhr = p; u.abortable = F; const m = function () { u.xhr = null; u.abortable = false; if (u.queuedRequests != null && u.queuedRequests.length > 0) { const e = u.queuedRequests.shift(); e() } }; const B = re(r, "hx-prompt"); if (B) { var x = prompt(B); if (x === null || !he(r, "htmx:prompt", { prompt: x, target: c })) { oe(s); m(); return e } } if (f && !D) { if (!confirm(f)) { oe(s); m(); return e } } let y = fn(r, c, x); if (t !== "get" && !pn(r)) { y["Content-Type"] = "application/x-www-form-urlencoded" } if (i.headers) { y = ce(y, i.headers) } const U = cn(r, t); let b = U.errors; const j = U.formData; if (i.values) { ln(j, qn(i.values)) } const V = qn(En(r)); const v = ln(j, V); let w = hn(v, r); if (Q.config.getCacheBusterParam && t === "get") { w.set("org.htmx.cache-buster", ee(c, "id") || "true") } if (n == null || n === "") { n = ne().location.href } const S = bn(r, "hx-request"); const _ = ie(r).boosted; let E = Q.config.methodsThatUseUrlParams.indexOf(t) >= 0; const C = { boosted: _, useUrlParams: E, formData: w, parameters: An(w), unfilteredFormData: v, unfilteredParameters: An(v), headers: y, target: c, verb: t, errors: b, withCredentials: i.credentials || S.credentials || Q.config.withCredentials, timeout: i.timeout || S.timeout || Q.config.timeout, path: n, triggeringEvent: o }; if (!he(r, "htmx:configRequest", C)) { oe(s); m(); return e } n = C.path; t = C.verb; y = C.headers; w = qn(C.parameters); b = C.errors; E = C.useUrlParams; if (b && b.length > 0) { he(r, "htmx:validation:halted", C); oe(s); m(); return e } const z = n.split("#"); const $ = z[0]; const O = z[1]; let R = n; if (E) { R = $; const Z = !w.keys().next().done; if (Z) { if (R.indexOf("?") < 0) { R += "?" } else { R += "&" } R += an(w); if (O) { R += "#" + O } } } if (!Tn(r, R, C)) { fe(r, "htmx:invalidPath", C); oe(l); return e } p.open(t.toUpperCase(), R, true); p.overrideMimeType("text/html"); p.withCredentials = C.withCredentials; p.timeout = C.timeout; if (S.noHeaders) { } else { for (const k in y) { if (y.hasOwnProperty(k)) { const Y = y[k]; Cn(p, k, Y) } } } const H = { xhr: p, target: c, requestConfig: C, etc: i, boosted: _, select: X, pathInfo: { requestPath: n, finalRequestPath: R, responsePath: null, anchor: O } }; p.onload = function () { try { const t = Hn(r); H.pathInfo.responsePath = On(p); M(r, H); if (H.keepIndicators !== true) { Qt(T, q) } he(r, "htmx:afterRequest", H); he(r, "htmx:afterOnLoad", H); if (!le(r)) { let e = null; while (t.length > 0 && e == null) { const n = t.shift(); if (le(n)) { e = n } } if (e) { he(e, "htmx:afterRequest", H); he(e, "htmx:afterOnLoad", H) } } oe(s); m() } catch (e) { fe(r, "htmx:onLoadError", ce({ error: e }, H)); throw e } }; p.onerror = function () { Qt(T, q); fe(r, "htmx:afterRequest", H); fe(r, "htmx:sendError", H); oe(l); m() }; p.onabort = function () { Qt(T, q); fe(r, "htmx:afterRequest", H); fe(r, "htmx:sendAbort", H); oe(l); m() }; p.ontimeout = function () { Qt(T, q); fe(r, "htmx:afterRequest", H); fe(r, "htmx:timeout", H); oe(l); m() }; if (!he(r, "htmx:beforeRequest", H)) { oe(s); m(); return e } var T = Zt(r); var q = Yt(r); se(["loadstart", "loadend", "progress", "abort"], function (t) { se([p, p.upload], function (e) { e.addEventListener(t, function (e) { he(r, "htmx:xhr:" + t, { lengthComputable: e.lengthComputable, loaded: e.loaded, total: e.total }) }) }) }); he(r, "htmx:beforeSend", H); const J = E ? null : mn(p, r, w); p.send(J); return e } function Nn(e, t) { const n = t.xhr; let r = null; let o = null; if (R(n, /HX-Push:/i)) { r = n.getResponseHeader("HX-Push"); o = "push" } else if (R(n, /HX-Push-Url:/i)) { r = n.getResponseHeader("HX-Push-Url"); o = "push" } else if (R(n, /HX-Replace-Url:/i)) { r = n.getResponseHeader("HX-Replace-Url"); o = "replace" } if (r) { if (r === "false") { return {} } else { return { type: o, path: r } } } const i = t.pathInfo.finalRequestPath; const s = t.pathInfo.responsePath; const l = re(e, "hx-push-url"); const c = re(e, "hx-replace-url"); const u = ie(e).boosted; let a = null; let f = null; if (l) { a = "push"; f = l } else if (c) { a = "replace"; f = c } else if (u) { a = "push"; f = s || i } if (f) { if (f === "false") { return {} } if (f === "true") { f = s || i } if (t.pathInfo.anchor && f.indexOf("#") === -1) { f = f + "#" + t.pathInfo.anchor } return { type: a, path: f } } else { return {} } } function In(e, t) { var n = new RegExp(e.code); return n.test(t.toString(10)) } function Pn(e) { for (var t = 0; t < Q.config.responseHandling.length; t++) { var n = Q.config.responseHandling[t]; if (In(n, e.status)) { return n } } return { swap: false } } function kn(e) { if (e) { const t = u("title"); if (t) { t.innerHTML = e } else { window.document.title = e } } } function Dn(o, i) { const s = i.xhr; let l = i.target; const e = i.etc; const c = i.select; if (!he(o, "htmx:beforeOnLoad", i)) return; if (R(s, /HX-Trigger:/i)) { Je(s, "HX-Trigger", o) } if (R(s, /HX-Location:/i)) { zt(); let e = s.getResponseHeader("HX-Location"); var t; if (e.indexOf("{") === 0) { t = S(e); e = t.path; delete t.path } Rn("get", e, t).then(function () { $t(e) }); return } const n = R(s, /HX-Refresh:/i) && s.getResponseHeader("HX-Refresh") === "true"; if (R(s, /HX-Redirect:/i)) { i.keepIndicators = true; location.href = s.getResponseHeader("HX-Redirect"); n && location.reload(); return } if (n) { i.keepIndicators = true; location.reload(); return } if (R(s, /HX-Retarget:/i)) { if (s.getResponseHeader("HX-Retarget") === "this") { i.target = o } else { i.target = ue(ae(o, s.getResponseHeader("HX-Retarget"))) } } const u = Nn(o, i); const r = Pn(s); const a = r.swap; let f = !!r.error; let h = Q.config.ignoreTitle || r.ignoreTitle; let d = r.select; if (r.target) { i.target = ue(ae(o, r.target)) } var g = e.swapOverride; if (g == null && r.swapOverride) { g = r.swapOverride } if (R(s, /HX-Retarget:/i)) { if (s.getResponseHeader("HX-Retarget") === "this") { i.target = o } else { i.target = ue(ae(o, s.getResponseHeader("HX-Retarget"))) } } if (R(s, /HX-Reswap:/i)) { g = s.getResponseHeader("HX-Reswap") } var p = s.response; var m = ce({ shouldSwap: a, serverResponse: p, isError: f, ignoreTitle: h, selectOverride: d, swapOverride: g }, i); if (r.event && !he(l, r.event, m)) return; if (!he(l, "htmx:beforeSwap", m)) return; l = m.target; p = m.serverResponse; f = m.isError; h = m.ignoreTitle; d = m.selectOverride; g = m.swapOverride; i.target = l; i.failed = f; i.successful = !f; if (m.shouldSwap) { if (s.status === 286) { lt(o) } Ft(o, function (e) { p = e.transformResponse(p, s, o) }); if (u.type) { zt() } var x = gn(o, g); if (!x.hasOwnProperty("ignoreTitle")) { x.ignoreTitle = h } l.classList.add(Q.config.swappingClass); let n = null; let r = null; if (c) { d = c } if (R(s, /HX-Reselect:/i)) { d = s.getResponseHeader("HX-Reselect") } const y = re(o, "hx-select-oob"); const b = re(o, "hx-select"); let e = function () { try { if (u.type) { he(ne().body, "htmx:beforeHistoryUpdate", ce({ history: u }, i)); if (u.type === "push") { $t(u.path); he(ne().body, "htmx:pushedIntoHistory", { path: u.path }) } else { Jt(u.path); he(ne().body, "htmx:replacedInHistory", { path: u.path }) } } $e(l, p, x, { select: d || b, selectOOB: y, eventInfo: i, anchor: i.pathInfo.anchor, contextElement: o, afterSwapCallback: function () { if (R(s, /HX-Trigger-After-Swap:/i)) { let e = o; if (!le(o)) { e = ne().body } Je(s, "HX-Trigger-After-Swap", e) } }, afterSettleCallback: function () { if (R(s, /HX-Trigger-After-Settle:/i)) { let e = o; if (!le(o)) { e = ne().body } Je(s, "HX-Trigger-After-Settle", e) } oe(n) } }) } catch (e) { fe(o, "htmx:swapError", i); oe(r); throw e } }; let t = Q.config.globalViewTransitions; if (x.hasOwnProperty("transition")) { t = x.transition } if (t && he(o, "htmx:beforeTransition", i) && typeof Promise !== "undefined" && document.startViewTransition) { const v = new Promise(function (e, t) { n = e; r = t }); const w = e; e = function () { document.startViewTransition(function () { w(); return v }) } } if (x.swapDelay > 0) { E().setTimeout(e, x.swapDelay) } else { e() } } if (f) { fe(o, "htmx:responseError", ce({ error: "Response Status Error Code " + s.status + " from " + i.pathInfo.requestPath }, i)) } } const Mn = {}; function Xn() { return { init: function (e) { return null }, getSelectors: function () { return null }, onEvent: function (e, t) { return true }, transformResponse: function (e, t, n) { return e }, isInlineSwap: function (e) { return false }, handleSwap: function (e, t, n, r) { return false }, encodeParameters: function (e, t, n) { return null } } } function Fn(e, t) { if (t.init) { t.init(n) } Mn[e] = ce(Xn(), t) } function Bn(e) { delete Mn[e] } function Un(e, n, r) { if (n == undefined) { n = [] } if (e == undefined) { return n } if (r == undefined) { r = [] } const t = te(e, "hx-ext"); if (t) { se(t.split(","), function (e) { e = e.replace(/ /g, ""); if (e.slice(0, 7) == "ignore:") { r.push(e.slice(7)); return } if (r.indexOf(e) < 0) { const t = Mn[e]; if (t && n.indexOf(t) < 0) { n.push(t) } } }) } return Un(ue(c(e)), n, r) } var jn = false; ne().addEventListener("DOMContentLoaded", function () { jn = true }); function Vn(e) { if (jn || ne().readyState === "complete") { e() } else { ne().addEventListener("DOMContentLoaded", e) } } function _n() { if (Q.config.includeIndicatorStyles !== false) { const e = Q.config.inlineStyleNonce ? ` nonce="${Q.config.inlineStyleNonce}"` : ""; ne().head.insertAdjacentHTML("beforeend", " ." + Q.config.indicatorClass + "{opacity:0} ." + Q.config.requestClass + " ." + Q.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;} ." + Q.config.requestClass + "." + Q.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;} ") } } function zn() { const e = ne().querySelector('meta[name="htmx-config"]'); if (e) { return S(e.content) } else { return null } } function $n() { const e = zn(); if (e) { Q.config = ce(Q.config, e) } } Vn(function () { $n(); _n(); let e = ne().body; kt(e); const t = ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']"); e.addEventListener("htmx:abort", function (e) { const t = e.target; const n = ie(t); if (n && n.xhr) { n.xhr.abort() } }); const n = window.onpopstate ? window.onpopstate.bind(window) : null; window.onpopstate = function (e) { if (e.state && e.state.htmx) { Wt(); se(t, function (e) { he(e, "htmx:restored", { document: ne(), triggerEvent: he }) }) } else { if (n) { n(e) } } }; E().setTimeout(function () { he(e, "htmx:load", {}); e = null }, 0) }); return Q }(); \ No newline at end of file diff --git a/static/js/sse.js b/static/js/sse.js new file mode 100644 index 0000000..da68ad1 --- /dev/null +++ b/static/js/sse.js @@ -0,0 +1,290 @@ +/* +Server Sent Events Extension +============================ +This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. + +*/ + +(function () { + /** @type {import("../htmx").HtmxInternalApi} */ + var api + + htmx.defineExtension('sse', { + + /** + * Init saves the provided reference to the internal HTMX API. + * + * @param {import("../htmx").HtmxInternalApi} api + * @returns void + */ + init: function (apiRef) { + // store a reference to the internal API. + api = apiRef + + // set a function in the public API for creating new EventSource objects + if (htmx.createEventSource == undefined) { + htmx.createEventSource = createEventSource + } + }, + + getSelectors: function () { + return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]'] + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + * @returns void + */ + onEvent: function (name, evt) { + var parent = evt.target || evt.detail.elt + switch (name) { + case 'htmx:beforeCleanupElement': + var internalData = api.getInternalData(parent) + // Try to remove remove an EventSource when elements are removed + var source = internalData.sseEventSource + if (source) { + api.triggerEvent(parent, 'htmx:sseClose', { + source, + type: 'nodeReplaced', + }) + internalData.sseEventSource.close() + } + + return + + // Try to create EventSources when elements are processed + case 'htmx:afterProcessNode': + ensureEventSourceOnElement(parent) + } + } + }) + + /// //////////////////////////////////////////// + // HELPER FUNCTIONS + /// //////////////////////////////////////////// + + /** + * createEventSource is the default method for creating new EventSource objects. + * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. + * + * @param {string} url + * @returns EventSource + */ + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }) + } + + /** + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * + * @param {HTMLElement} elt + */ + function registerSSE(elt) { + // Add message handlers for every `sse-swap` attribute + if (api.getAttributeValue(elt, 'sse-swap')) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource) + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement) + var source = internalData.sseEventSource + + var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap') + var sseEventNames = sseSwapAttr.split(',') + + for (var i = 0; i < sseEventNames.length; i++) { + const sseEventName = sseEventNames[i].trim() + const listener = function (event) { + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { + return + } + + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(elt)) { + source.removeEventListener(sseEventName, listener) + return + } + + // swap the response into the DOM and trigger a notification + if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { + return + } + swap(elt, event.data) + api.triggerEvent(elt, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(elt).sseEventListener = listener + source.addEventListener(sseEventName, listener) + } + } + + // Add message handlers for every `hx-trigger="sse:*"` attribute + if (api.getAttributeValue(elt, 'hx-trigger')) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource) + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement) + var source = internalData.sseEventSource + + var triggerSpecs = api.getTriggerSpecs(elt) + triggerSpecs.forEach(function (ts) { + if (ts.trigger.slice(0, 4) !== 'sse:') { + return + } + + var listener = function (event) { + if (maybeCloseSSESource(sourceElement)) { + return + } + if (!api.bodyContains(elt)) { + source.removeEventListener(ts.trigger.slice(4), listener) + } + // Trigger events to be handled by the rest of htmx + htmx.trigger(elt, ts.trigger, event) + htmx.trigger(elt, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(elt).sseEventListener = listener + source.addEventListener(ts.trigger.slice(4), listener) + }) + } + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + if (elt == null) { + return null + } + + // handle extension source creation attribute + if (api.getAttributeValue(elt, 'sse-connect')) { + var sseURL = api.getAttributeValue(elt, 'sse-connect') + if (sseURL == null) { + return + } + + ensureEventSource(elt, sseURL, retryCount) + } + + registerSSE(elt) + } + + function ensureEventSource(elt, url, retryCount) { + var source = htmx.createEventSource(url) + + source.onerror = function (err) { + // Log an error event + api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source }) + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0 + retryCount = Math.max(Math.min(retryCount * 2, 128), 1) + var timeout = retryCount * 500 + window.setTimeout(function () { + ensureEventSourceOnElement(elt, retryCount) + }, timeout) + } + } + + source.onopen = function (evt) { + api.triggerEvent(elt, 'htmx:sseOpen', { source }) + + if (retryCount && retryCount > 0) { + const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]") + for (let i = 0; i < childrenToFix.length; i++) { + registerSSE(childrenToFix[i]) + } + // We want to increase the reconnection delay for consecutive failed attempts only + retryCount = 0 + } + } + + api.getInternalData(elt).sseEventSource = source + + + var closeAttribute = api.getAttributeValue(elt, "sse-close"); + if (closeAttribute) { + // close eventsource when this message is received + source.addEventListener(closeAttribute, function () { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'message', + }) + source.close() + }); + } + } + + /** + * maybeCloseSSESource confirms that the parent element still exists. + * If not, then any associated SSE source is closed and the function returns true. + * + * @param {HTMLElement} elt + * @returns boolean + */ + function maybeCloseSSESource(elt) { + if (!api.bodyContains(elt)) { + var source = api.getInternalData(elt).sseEventSource + if (source != undefined) { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'nodeMissing', + }) + source.close() + // source = null + return true + } + } + return false + } + + + /** + * @param {HTMLElement} elt + * @param {string} content + */ + function swap(elt, content) { + api.withExtensions(elt, function (extension) { + content = extension.transformResponse(content, null, elt) + }) + + var swapSpec = api.getSwapSpecification(elt) + var target = api.getTarget(elt) + api.swap(target, content, swapSpec) + } + + + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null + } +})() diff --git a/static/styles/main.css b/static/styles/main.css new file mode 100644 index 0000000..623c280 --- /dev/null +++ b/static/styles/main.css @@ -0,0 +1,1364 @@ +/* reset */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* tokens */ +:root { + --bg: #0d0d12; + --bg-surface: #15151e; + --bg-surface-hover: #1c1c28; + --border: #232332; + --border-accent: rgba(99, 102, 241, 0.4); + + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --accent: #6366f1; /* Indigo */ + --accent-hover: #4f46e5; + --accent-glow: rgba(99, 102, 241, 0.15); + + --success-bg: rgba(16, 185, 129, 0.05); + --success-border: rgba(16, 185, 129, 0.2); + --success-text: #10b981; + --success-label: #34d399; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 24px; + --radius-full: 9999px; + + --shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-btn: 0 0 30px var(--accent-glow); + + --font: "Plus Jakarta Sans", "Inter", system-ui, sans-serif; + --font-heading: "Outfit", "Inter", sans-serif; + --font-mono: "JetBrains Mono", monospace; +} + +/* base */ +html { + color-scheme: dark; +} + +body { + font-family: var(--font); + background-color: var(--bg); + color: var(--text-primary); + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + line-height: 1.6; +} + +/* card */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 3.5rem 3rem; + max-width: 480px; + width: 100%; + text-align: center; + position: relative; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +/* app shell body — reset centering, fill viewport */ +body.app-body { + display: block; + padding: 0; + min-height: 100vh; + height: 100vh; + overflow: hidden; +} + +/* top glow line */ +.card::before { + content: ""; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.6; +} + +/* logo / wordmark */ +.logo { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +.logo-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--accent), #6366f1); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + box-shadow: 0 0 16px var(--accent-glow); +} + +/* heading */ +h1 { + font-size: 1.875rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-primary); + line-height: 1.2; +} + +h1 span { + background: linear-gradient(135deg, #c4b5fd, var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.subtitle { + font-size: 0.9rem; + color: var(--text-secondary); + margin-top: 0.4rem; + margin-bottom: 2.25rem; +} + +/* divider */ +.divider { + height: 1px; + background: var(--border); + margin: 2rem 0; +} + +/* button */ +.ping-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: linear-gradient(135deg, var(--accent), #6366f1); + color: #fff; + font-family: var(--font); + font-size: 0.9rem; + font-weight: 600; + padding: 0.65rem 1.6rem; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + letter-spacing: 0.01em; + transition: + opacity 0.2s ease, + transform 0.15s ease, + box-shadow 0.2s ease; + box-shadow: var(--shadow-btn); + position: relative; + overflow: hidden; +} + +.ping-btn::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(rgba(255, 255, 255, 0.12), transparent); + pointer-events: none; +} + +.ping-btn:hover { + opacity: 0.9; + box-shadow: 0 0 32px var(--accent-glow); + transform: translateY(-1px); +} + +.ping-btn:active { + transform: scale(0.97) translateY(0); +} + +.ping-btn.htmx-request { + opacity: 0.6; + cursor: wait; + transform: none; + box-shadow: none; +} + +/* ping result area */ +#ping-result { + margin-top: 1.75rem; + min-height: 3rem; + display: flex; + align-items: center; + justify-content: center; +} + +/* result box */ +.result-box { + background: var(--success-bg); + border: 1px solid var(--success-border); + border-radius: var(--radius-md); + padding: 0.85rem 1.5rem; + width: 100%; + text-align: left; + display: flex; + align-items: center; + gap: 1rem; + animation: fadeSlideIn 0.25s ease forwards; +} + +.result-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success-text); + box-shadow: 0 0 8px var(--success-text); + flex-shrink: 0; + animation: pulse 2s ease infinite; +} + +.result-content { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.result-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--success-label); +} + +.result-message { + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--success-text); +} + +/* stats row */ +.stats { + display: flex; + gap: 1rem; + margin-top: 2rem; +} + +.stat { + flex: 1; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.85rem 1rem; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.stat:hover { + background: var(--bg-surface-hover); + border-color: var(--border-accent); +} + +.stat-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.stat-label { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +/* footer */ +footer { + margin-top: 2.5rem; + font-size: 0.78rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.4rem; +} + +footer a { + color: var(--text-secondary); + text-decoration: none; + transition: color 0.2s; +} + +footer a:hover { + color: var(--accent); +} + +/* animations */ +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* scrollbar */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 99px; +} + +/* Auth */ + +/* body variant — keeps the grid bg from body, just re-centers */ +.auth-body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; +} + +/* narrower card */ +.auth-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 2.5rem 2.25rem; + max-width: 420px; + width: 100%; + text-align: center; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: relative; + overflow: hidden; +} + +.auth-card::before { + content: ""; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.6; +} + +/* logo row */ +.auth-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +/* headings */ +.auth-title { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-primary); + line-height: 1.2; + margin-bottom: 0.35rem; +} + +.auth-subtitle { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 2rem; +} + +/* form layout */ +.auth-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + text-align: left; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.form-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.form-input { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.7rem 1rem; + color: var(--text-primary); + font-family: var(--font); + font-size: 0.95rem; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.form-input::placeholder { + color: var(--text-muted); +} + +.form-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +/* error message box */ +.form-error { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: var(--radius-md); + padding: 0.7rem 1rem; + font-size: 0.85rem; + color: #f87171; + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* full-width primary button */ +.btn-primary { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--accent); + color: #fff; + font-family: var(--font); + font-size: 0.95rem; + font-weight: 600; + padding: 0.85rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2); +} + +.btn-primary:active { + transform: translateY(0); +} + +/* footer link line below the card */ +.auth-footer { + margin-top: 1.5rem; + font-size: 0.85rem; + color: var(--text-muted); + text-align: center; +} + +.auth-footer a { + color: var(--accent); + text-decoration: none; + transition: opacity 0.2s; +} + +.auth-footer a:hover { + opacity: 0.8; +} + +/* "or" divider */ +.auth-divider { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-muted); + font-size: 0.8rem; +} + +.auth-divider::before, +.auth-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +/* Dashboard */ + +.app-shell { + display: block; + height: 100vh; + width: 100%; + overflow: hidden; + background: #09090b; +} + +.brand-mark { + display: flex; + align-items: center; + gap: 0.75rem; + text-decoration: none; +} + +.brand-mark .logo-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, var(--accent), #4f46e5); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 1.1rem; + font-weight: bold; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); + flex-shrink: 0; +} + +.brand-mark .logo-text { + font-family: var(--font-heading); + font-size: 1.15rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.brand-mark .logo-text span { + color: var(--accent); +} + +.user-row { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-md); +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 8px; + background: #27272a; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-primary); + font-weight: 600; + font-size: 0.85rem; + flex-shrink: 0; +} + +.user-info { + display: flex; + flex-direction: column; + gap: 0.1rem; + overflow: hidden; + flex: 1; +} + +.user-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-role { + font-size: 0.72rem; + color: var(--text-muted); +} + +.sign-out-btn { + width: 100%; + margin-top: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.75rem; + border-radius: var(--radius-md); + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.85rem; + font-family: var(--font); + cursor: pointer; + transition: all 0.15s; +} + +.sign-out-btn:hover { + border-color: rgba(239, 68, 68, 0.4); + color: #f87171; + background: rgba(239, 68, 68, 0.06); +} + +/* main content area */ +.main-content { + display: flex; + flex-direction: column; + overflow-y: auto; + background: #0d0d12; + min-height: 100vh; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 2.5rem; + border-bottom: 1px solid #18181b; + background: rgba(13, 13, 18, 0.94); + backdrop-filter: blur(16px); + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 5; + gap: 1rem; +} + +.topbar-brand, +.topbar-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.topbar-brand { + min-width: 0; +} + +.topbar-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.room-title { + max-width: 24rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar-btn { + width: auto; + padding: 0.5rem 1rem; +} + +.topbar-user { + display: inline-flex; + align-items: center; + gap: 0.7rem; + min-height: 44px; + padding: 0.3rem 0.85rem 0.3rem 0.3rem; + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.topbar-user-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + line-height: 1; +} + +.topbar-user .user-avatar { + width: 38px; + height: 38px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.04); + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.topbar-link-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0.55rem 0.9rem; + border-radius: var(--radius-full); + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.85rem; + font-family: var(--font); + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.topbar-link-btn:hover { + border-color: rgba(239, 68, 68, 0.4); + color: #f87171; + background: rgba(239, 68, 68, 0.06); +} + +.page-content { + padding: 2rem; + flex: 1; + max-width: 900px; + width: 100%; + margin: 0 auto; +} + +/* welcome hero */ +.welcome-hero { + padding: 2rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: 2rem; + position: relative; + overflow: hidden; +} + +.welcome-hero::before { + content: ""; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.6; +} + +.welcome-greeting { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.35rem; +} + +.welcome-greeting span { + background: linear-gradient(135deg, #c4b5fd, var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.welcome-sub { + color: var(--text-secondary); + font-size: 0.9rem; +} + +/* dashboard stat cards grid */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.dash-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 1.25rem 1.5rem; + text-align: left; + position: relative; + overflow: hidden; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.dash-card:hover { + background: var(--bg-surface-hover); + border-color: var(--border-accent); +} + +.dash-card-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.dash-card-value { + font-size: 1.5rem; + font-weight: 700; + font-family: var(--font-mono); + color: var(--text-primary); +} + +/* rooms grid */ +.rooms-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.room-card { + display: block; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem 1.5rem; + text-decoration: none; + transition: all 0.15s; + position: relative; +} + +.room-card:hover { + background: var(--bg-surface-hover); + border-color: var(--border-accent); + transform: translateY(-2px); +} + +.room-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.room-name { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +.room-code { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--accent); + background: rgba(139, 92, 246, 0.12); + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); +} + +.room-meta { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.room-owner-badge { + position: absolute; + top: 1rem; + right: 1rem; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent); + background: rgba(139, 92, 246, 0.15); + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); +} + +.empty-state { + text-align: center; + padding: 3rem; + color: var(--text-secondary); +} + +/* modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(9, 9, 11, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal { + background: #18181b; + border: 1px solid #27272a; + border-radius: var(--radius-lg); + padding: 2.5rem; + max-width: 420px; + width: 90%; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.modal-header h2 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.modal-close { + background: #27272a; + border: none; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.modal-close:hover { + background: #3f3f46; + color: var(--text-primary); +} + +/* room detail */ +.back-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-md); + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--text-secondary); + text-decoration: none; + transition: all 0.15s; +} + +.back-btn:hover { + background: var(--bg-surface-hover); + color: var(--text-primary); +} + +.room-code-badge { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--accent); + background: rgba(139, 92, 246, 0.12); + padding: 0.25rem 0.75rem; + border-radius: var(--radius-md); +} + +.scale-badge { + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-surface); + padding: 0.25rem 0.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); +} + +.room-layout { + display: grid; + grid-template-columns: 1fr 240px; + gap: 1.5rem; + min-height: 400px; +} + +.room-stream { + display: none; +} + +.stories-panel, +.members-panel { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; +} + +.panel-title { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.stories-list, +.members-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.story-card { + background: #18181b; + border: 1px solid #27272a; + border-radius: var(--radius-md); + padding: 1.25rem; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + cursor: default; +} + +.story-card:hover { + border-color: #3f3f46; + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); +} + +.story-revealed { + border-color: rgba(99, 102, 241, 0.2); + background: rgba(99, 102, 241, 0.03); +} + +.story-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.story-title { + font-size: 0.95rem; + font-weight: 500; + color: var(--text-primary); + flex: 1 1 auto; + word-break: break-word; + margin-top: 0.15rem; /* align text baseline with buttons */ +} + +.story-actions { + display: flex; + gap: 0.4rem; + align-items: center; + flex-wrap: wrap; + flex: 0 0 auto; +} + +.story-action-form { + margin: 0; +} + +.story-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 26px; + padding: 0 0.6rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s; + border: 1px solid transparent; +} + +.story-btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.story-btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.story-btn-secondary { + background: transparent; + color: var(--text-secondary); + border-color: var(--border); +} +.story-btn-secondary:hover { + background: var(--bg-surface-hover); + color: var(--text-primary); +} + +.story-badge { + display: inline-flex; + align-items: center; + justify-content: center; + height: 26px; + padding: 0 0.6rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: var(--radius-sm); +} + +.story-badge-active { + color: var(--success-text); + border: 1px solid var(--success-border); + background: var(--success-bg); +} + +.story-badge-revealed { + color: var(--accent); + border: 1px solid rgba(139, 92, 246, 0.4); + background: rgba(139, 92, 246, 0.1); +} + +.story-points { + font-family: var(--font-mono); + font-size: 1rem; + font-weight: 700; + color: var(--accent); + background: rgba(139, 92, 246, 0.15); + padding: 0.25rem 0.75rem; + border-radius: var(--radius-md); +} + +.vote-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.vote-option { + background: #1c1c28; + border: 1px solid #2d2d3d; + border-radius: var(--radius-md); + padding: 0.5rem 0.85rem; + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.vote-option:hover { + background: var(--bg-surface-hover); + border-color: var(--accent); + color: var(--text-primary); +} + +.vote-option.vote-selected { + border-color: var(--accent); + background: var(--accent); + color: #fff; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} + +.story-active { + border-color: rgba(16, 185, 129, 0.2); + background: rgba(16, 185, 129, 0.02); +} + +.story-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + padding: 0; + border-radius: 14px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; +} + +.story-action-btn svg { + width: 16px; + height: 16px; +} + +.story-action-btn:hover { + background: var(--bg-surface-hover); + color: var(--text-primary); + border-color: var(--border-accent); + transform: translateY(-1px); +} + +.story-action-btn-toggle { + color: var(--accent); + border-color: rgba(99, 102, 241, 0.35); + background: rgba(99, 102, 241, 0.08); +} + +.story-action-btn-toggle:hover { + color: #c7d2fe; + border-color: rgba(99, 102, 241, 0.5); + background: rgba(99, 102, 241, 0.16); +} + +.story-action-btn-activate { + color: #f8fafc; + border-color: rgba(148, 163, 184, 0.26); + background: rgba(148, 163, 184, 0.08); +} + +.story-action-btn-activate:hover { + color: #ffffff; + border-color: rgba(148, 163, 184, 0.42); + background: rgba(148, 163, 184, 0.16); +} + +.story-action-btn-reset { + color: var(--success-text); + border-color: rgba(16, 185, 129, 0.26); + background: rgba(16, 185, 129, 0.06); +} + +.story-action-btn-reset:hover { + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.4); + background: rgba(16, 185, 129, 0.14); +} + +.story-action-btn.story-action-delete:hover { + border-color: rgba(239, 68, 68, 0.4); + color: #f87171; + background: rgba(239, 68, 68, 0.06); +} + +.revealed-votes { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.revealed-vote { + display: flex; + align-items: center; + gap: 0.4rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.3rem 0.6rem; +} + +.revealed-vote-user { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.revealed-vote-value { + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: 700; + color: var(--accent); + background: rgba(139, 92, 246, 0.15); + padding: 0.1rem 0.4rem; + border-radius: var(--radius-sm); +} + +.member-row { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.5rem; + border-radius: var(--radius-md); +} + +.member-name { + font-size: 0.9rem; + color: var(--text-secondary); +} + +/* SSE indicator */ +.sse-indicator { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-full); + background: var(--bg-surface-hover); + border: 1px solid var(--border); +} + +.sse-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted); +} + +.sse-connected .sse-indicator { + color: var(--success-text); + background: var(--success-bg); + border-color: var(--success-border); +} + +.sse-connected .sse-dot { + background: var(--success-text); + animation: pulse 2s ease infinite; +} + +@media (max-width: 768px) { + .topbar { + padding: 1rem 1.1rem; + align-items: flex-start; + flex-direction: column; + } + + .topbar-brand, + .topbar-actions { + width: 100%; + flex-wrap: wrap; + } + + .topbar-actions { + justify-content: flex-start; + } + + .page-content { + padding: 1.1rem; + } + + .room-title { + max-width: 100%; + } + + .topbar-user { + order: 2; + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..4024fdb --- /dev/null +++ b/templates/index.html @@ -0,0 +1,120 @@ + + + {{template "app-head" (dict "Title" "Dashboard" "UseHTMX" true "UseSSE" + false)}} + +
+
+
+
+ {{template "brand-mark" .}} + Planning Rooms +
+
+ + {{template "session-controls" .}} +
+
+ +
+
+

+ Welcome back, {{.Username}} +

+

+ Create or join a planning room to start estimating. +

+
+ + {{if .Rooms}} +
+ {{range .Rooms}} +
+ +
+ {{.Name}} +
+ {{if .IsOwner}} + + Owner + + {{end}} + {{.Code}} + {{if .IsOwner}} + + {{end}} +
+
+
+ {{.Scale}} + {{.MemberCount}} members +
+
+ {{end}} +
+ {{else}} +
+

No rooms yet. Create one to get started.

+
+ {{end}} +
+
+
+ + + + diff --git a/templates/layouts.html b/templates/layouts.html new file mode 100644 index 0000000..8698468 --- /dev/null +++ b/templates/layouts.html @@ -0,0 +1,85 @@ +{{define "app-head"}} + + + + {{.Title}} — SprintPadawan + + + + + + + + + + + + + + + + {{if .UseHTMX}} + + {{end}} {{if .UseSSE}} + + {{end}} + +{{end}} {{define "auth-head"}} + + + + {{.Title}} — SprintPadawan + + + + + + + + + + + + + + + + +{{end}} {{define "brand-mark"}} + + SprintPadawan + +{{end}} {{define "session-controls"}} +
+
{{slice .Username 0 1}}
+ {{.Username}} +
+
+ +
+{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d97dbbb --- /dev/null +++ b/templates/login.html @@ -0,0 +1,74 @@ + + + {{template "auth-head" (dict "Title" "Sign In")}} + +
+ +

Sign in to your account

+ + {{if .Error}} +
+ + + + + + {{.Error}} +
+ {{end}} + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..c49bb8b --- /dev/null +++ b/templates/register.html @@ -0,0 +1,91 @@ + + + {{template "auth-head" (dict "Title" "Register")}} + +
+ +

Create your account

+ + {{if .Error}} +
+ + + + + + {{.Error}} +
+ {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + diff --git a/templates/room.html b/templates/room.html new file mode 100644 index 0000000..5d37c1b --- /dev/null +++ b/templates/room.html @@ -0,0 +1,98 @@ +{{define "room.html"}} + + + {{template "app-head" (dict "Title" .Room.Name "UseHTMX" true "UseSSE" + true)}} + +
+
+
+
+ {{template "brand-mark" .}} + + + + + + + {{.Room.Name}} + {{.Room.Code}} +
+
+ {{.Room.Scale}} + {{if .IsOwner}} + + {{end}} {{template "session-controls" .User}} +
+
+ +
+
+ + +
+ +
+ {{template "stories-panel" .}} {{template + "members-panel" .}} +
+
+
+
+ + + + {{template "sse-script"}} + + +{{end}} diff --git a/templates/room_form.html b/templates/room_form.html new file mode 100644 index 0000000..b952a2e --- /dev/null +++ b/templates/room_form.html @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/templates/room_partials.html b/templates/room_partials.html new file mode 100644 index 0000000..c8a6bbf --- /dev/null +++ b/templates/room_partials.html @@ -0,0 +1,316 @@ +{{define "stories-panel"}} +
+
+

Stories

+
+ + Live +
+
+
+ {{template "stories-list" .}} +
+
+{{end}} + +{{define "stories-list"}} {{range .Stories}} {{$isActive := eq (derefInt +$.Room.ActiveStoryID) .ID}} +
+
+ {{.Title}} +
+ {{if $isActive}} + Active + {{end}} {{if $.IsOwner}} {{if .Voted}} +
+ +
+ {{else}} +
+ + +
+ {{end}} {{if not $isActive}} +
+ + +
+ {{end}} {{if $.IsOwner}} +
+ +
+ + + {{end}} {{end}} +
+
+ + {{if $isActive}} +
+ {{template "vote-area" (dict "RoomData" $ "Story" .)}} +
+ {{end}} {{if .Voted}} +
+ {{range (index $.StoryVotes .ID)}} +
+ {{.Username}} + {{.Value}} +
+ {{end}} +
+ {{end}} +
+{{else}} +
+

No stories yet. Add one to start voting.

+
+{{end}} {{end}} + +{{define "vote-area"}} {{$data := .RoomData}} {{$story := .Story}} {{$userVote +:= index $data.UserVotes $story.ID}} +
+ {{range scaleToOptions $data.Room.Scale}} + + {{end}} +
+{{end}} + +{{define "members-panel"}} +
+

Members ({{len .Members}})

+
+ {{range .Members}} +
+
+ {{slice .Username 0 1}} +
+ {{.Username}} + {{if .HasVoted}} + + + + {{end}} +
+ {{end}} +
+
+{{end}} + +{{define "sse-script"}} + +{{end}} diff --git a/templates/story_edit.html b/templates/story_edit.html new file mode 100644 index 0000000..ea7340c --- /dev/null +++ b/templates/story_edit.html @@ -0,0 +1,34 @@ + diff --git a/templates/story_form.html b/templates/story_form.html new file mode 100644 index 0000000..0740be3 --- /dev/null +++ b/templates/story_form.html @@ -0,0 +1,34 @@ + diff --git a/templates/vote_form.html b/templates/vote_form.html new file mode 100644 index 0000000..be43428 --- /dev/null +++ b/templates/vote_form.html @@ -0,0 +1,15 @@ +{{$userVote := .UserVote}} {{$roomID := .RoomID}} {{$storyID := .Story.ID}} +
+ {{range scaleToOptions .Scale}} + + {{end}} +