Compare commits

12 Commits

Author SHA1 Message Date
atridad 7df663d9c4 Refactor 2026-04-28 16:00:39 -06:00
atridad 1e998dabf3 Tidy deps 2026-04-28 15:47:45 -06:00
atridad 01ca54ce6f Delete rooms done 2026-04-28 15:47:21 -06:00
atridad 3828c146ee Fonts :) 2026-04-28 15:41:22 -06:00
atridad 548cb92ca3 Image Optimization 2026-04-28 15:30:09 -06:00
atridad 7420e2b890 0.1.0 - First pass at the app 2026-04-28 15:26:55 -06:00
atridad 73aff92505 Added embed, nix, and a makefile 2026-04-28 14:34:20 -06:00
atridad 85a2a3116b Finished :) 2026-04-27 16:55:02 -06:00
atridad cb4a210567 :) 2026-04-27 16:35:07 -06:00
atridad 3c7be22019 Cleaned up UI and CSS 2026-04-24 00:08:39 -06:00
atridad 193391d837 Basic features done 2026-04-24 00:00:19 -06:00
atridad b8edbcb403 Basic structure 2026-04-23 22:33:33 -06:00
51 changed files with 4597 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
ROOT_DIR=./data
+1
View File
@@ -0,0 +1 @@
use flake
+15
View File
@@ -0,0 +1,15 @@
# binary
sprintpadawan
server
main
# db
app.db
app.db-wal
# env
.env
# os
.DS_Store
.direnv/
+21
View File
@@ -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"]
+13
View File
@@ -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
+28 -1
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
0.2.0
+89
View File
@@ -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)
}
+53
View File
@@ -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)
}
}
+155
View File
@@ -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)
}
+32
View File
@@ -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))
}
+59
View File
@@ -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()
}
}
}
+277
View File
@@ -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)
}
+121
View File
@@ -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
}
+233
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
services:
app:
build: .
container_name: sprintpadawan
ports:
- "8080:8080"
volumes:
- ${ROOT_DIR:-./data}:/data
restart: unless-stopped
Generated
+61
View File
@@ -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
}
+39
View File
@@ -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
'';
};
}
);
}
+16
View File
@@ -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
)
+22
View File
@@ -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=
+134
View File
@@ -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)
}
}
}
+248
View File
@@ -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
}
+189
View File
@@ -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
}
+82
View File
@@ -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
}
+82
View File
@@ -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)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+63
View File
@@ -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');
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
File diff suppressed because one or more lines are too long
+290
View File
@@ -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
}
})()
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
{{template "app-head" (dict "Title" "Dashboard" "UseHTMX" true "UseSSE"
false)}}
<body class="app-body">
<div class="app-shell">
<div class="main-content">
<header class="topbar">
<div class="topbar-brand">
{{template "brand-mark" .}}
<span class="topbar-title">Planning Rooms</span>
</div>
<div class="topbar-actions">
<button
class="btn-primary topbar-btn"
hx-get="/rooms/new"
hx-target="body"
hx-swap="beforeend"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Create Room
</button>
{{template "session-controls" .}}
</div>
</header>
<main class="page-content">
<div class="welcome-hero">
<p class="welcome-greeting">
Welcome back, <span>{{.Username}}</span>
</p>
<p class="welcome-sub">
Create or join a planning room to start estimating.
</p>
</div>
{{if .Rooms}}
<div class="rooms-grid">
{{range .Rooms}}
<div class="room-card">
<a href="/rooms/{{.ID}}" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1;"></a>
<div class="room-header" style="position: relative; z-index: 2; pointer-events: none;">
<span class="room-name">{{.Name}}</span>
<div
style="
display: flex;
gap: 0.5rem;
align-items: center;
pointer-events: auto;
"
>
{{if .IsOwner}}
<span
class="room-owner-badge"
style="
position: static;
margin: 0;
padding: 0.2rem 0.5rem;
"
>
Owner
</span>
{{end}}
<span class="room-code">{{.Code}}</span>
{{if .IsOwner}}
<button
type="button"
class="story-action-btn story-action-delete"
hx-post="/rooms/{{.ID}}/delete"
hx-target="closest .room-card"
hx-swap="outerHTML"
hx-confirm="Delete this room?"
title="Delete Room"
aria-label="Delete Room"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
{{end}}
</div>
</div>
<div class="room-meta" style="position: relative; z-index: 2; pointer-events: none;">
<span class="room-scale">{{.Scale}}</span>
<span class="room-members"
>{{.MemberCount}} members</span
>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state">
<p>No rooms yet. Create one to get started.</p>
</div>
{{end}}
</main>
</div>
</div>
<div id="modal-container"></div>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
{{define "app-head"}}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}} — SprintPadawan</title>
<meta
name="description"
content="A lightweight real-time sprint planning tool."
/>
<meta property="og:title" content="{{.Title}} - SprintPadawan" />
<meta
property="og:description"
content="A lightweight real-time sprint planning tool."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="/static/img/logo.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{.Title}} - SprintPadawan" />
<meta
name="twitter:description"
content="A lightweight real-time sprint planning tool."
/>
<meta name="twitter:image" content="/static/img/logo.png" />
<meta name="theme-color" content="#6366f1" />
<link rel="icon" href="/static/img/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/static/img/logo.webp" />
<link rel="apple-touch-icon" href="/static/img/logo.png" />
<link rel="stylesheet" href="/static/fonts/fonts.css" />
<link rel="stylesheet" href="/static/styles/main.css" />
{{if .UseHTMX}}
<script src="/static/js/htmx.min.js" defer></script>
{{end}} {{if .UseSSE}}
<script src="/static/js/sse.js" defer></script>
{{end}}
</head>
{{end}} {{define "auth-head"}}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}} — SprintPadawan</title>
<meta
name="description"
content="A lightweight web application for agile sprint planning."
/>
<meta property="og:title" content="{{.Title}} — SprintPadawan" />
<meta
property="og:description"
content="A lightweight web application for agile sprint planning."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="/static/img/logo.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{.Title}} — SprintPadawan" />
<meta
name="twitter:description"
content="A lightweight web application for agile sprint planning."
/>
<meta name="twitter:image" content="/static/img/logo.png" />
<meta name="theme-color" content="#6366f1" />
<link rel="icon" href="/static/img/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/static/img/logo.webp" />
<link rel="apple-touch-icon" href="/static/img/logo.png" />
<link rel="stylesheet" href="/static/fonts/fonts.css" />
<link rel="stylesheet" href="/static/styles/main.css" />
</head>
{{end}} {{define "brand-mark"}}
<a href="/" class="brand-mark">
<img
src="/static/img/logo.webp"
alt="SprintPadawan"
height="32"
width="32"
fetchpriority="high"
decoding="async"
/>
</a>
{{end}} {{define "session-controls"}}
<div class="topbar-user">
<div class="user-avatar">{{slice .Username 0 1}}</div>
<span class="topbar-user-name">{{.Username}}</span>
</div>
<form method="POST" action="/logout">
<button class="topbar-link-btn" type="submit">Sign Out</button>
</form>
{{end}}
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
{{template "auth-head" (dict "Title" "Sign In")}}
<body>
<div class="auth-card">
<div class="auth-logo">
<img
src="/static/img/logo.webp"
alt="SprintPadawan"
height="56"
width="56"
fetchpriority="high"
decoding="async"
/>
</div>
<p class="auth-subtitle">Sign in to your account</p>
{{if .Error}}
<div class="form-error">
<svg
xmlns="http://www.w3.org/2000/svg"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{{.Error}}
</div>
{{end}}
<form class="auth-form" method="POST" action="/login">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input
class="form-input"
type="text"
id="username"
name="username"
placeholder="your username"
autocomplete="username"
required
/>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input
class="form-input"
type="password"
id="password"
name="password"
placeholder="••••••••"
autocomplete="current-password"
required
/>
</div>
<button class="btn-primary" type="submit">Sign In</button>
</form>
<p class="auth-footer">
No account? <a href="/register">Create one</a>
</p>
</div>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
{{template "auth-head" (dict "Title" "Register")}}
<body>
<div class="auth-card">
<div class="auth-logo">
<img
src="/static/img/logo.webp"
alt="SprintPadawan"
height="56"
width="56"
fetchpriority="high"
decoding="async"
/>
</div>
<p class="auth-subtitle">Create your account</p>
{{if .Error}}
<div class="form-error">
<svg
xmlns="http://www.w3.org/2000/svg"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{{.Error}}
</div>
{{end}}
<form class="auth-form" method="POST" action="/register">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input
class="form-input"
type="text"
id="username"
name="username"
placeholder="choose a username"
autocomplete="username"
required
/>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input
class="form-input"
type="password"
id="password"
name="password"
placeholder="••••••••"
autocomplete="new-password"
required
/>
</div>
<div class="form-group">
<label class="form-label" for="confirm_password"
>Confirm Password</label
>
<input
class="form-input"
type="password"
id="confirm_password"
name="confirm_password"
placeholder="••••••••"
autocomplete="new-password"
required
/>
</div>
<button class="btn-primary" type="submit">
Create Account
</button>
</form>
<p class="auth-footer">
Already have an account? <a href="/login">Sign in</a>
</p>
</div>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
{{define "room.html"}}
<!doctype html>
<html lang="en">
{{template "app-head" (dict "Title" .Room.Name "UseHTMX" true "UseSSE"
true)}}
<body class="app-body">
<div class="app-shell">
<div class="main-content">
<header class="topbar">
<div class="topbar-brand">
{{template "brand-mark" .}}
<a href="/" class="back-btn">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<span class="topbar-title room-title"
>{{.Room.Name}}</span
>
<span class="room-code-badge">{{.Room.Code}}</span>
</div>
<div class="topbar-actions">
<span class="scale-badge">{{.Room.Scale}}</span>
{{if .IsOwner}}
<button
hx-get="/rooms/{{.Room.ID}}/stories/new"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-primary topbar-btn"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Story
</button>
{{end}} {{template "session-controls" .User}}
</div>
</header>
<main class="page-content">
<div
id="room-stream"
class="room-stream"
hx-ext="sse"
sse-connect="/sse/{{.Room.ID}}"
>
<div
hidden
hx-get="/rooms/{{.Room.ID}}/partial/stories"
hx-trigger="sse:stories"
hx-target="#stories-panel"
hx-swap="outerHTML"
></div>
<div
hidden
hx-get="/rooms/{{.Room.ID}}/partial/members"
hx-trigger="sse:members"
hx-target="#members-panel"
hx-swap="outerHTML"
></div>
</div>
<div class="room-layout">
{{template "stories-panel" .}} {{template
"members-panel" .}}
</div>
</main>
</div>
</div>
<div id="modal-container"></div>
{{template "sse-script"}}
</body>
</html>
{{end}}
+31
View File
@@ -0,0 +1,31 @@
<div class="modal-overlay" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>Create Room</h2>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">&times;</button>
</div>
<form method="POST" action="/rooms/create" class="auth-form">
<div class="form-group">
<label class="form-label">Room Name</label>
<input class="form-input" type="text" name="name" placeholder="Sprint Planning" required />
</div>
<div class="form-group">
<label class="form-label">Voting Scale</label>
<select class="form-input" name="scale">
<option value="fibonacci">Fibonacci (1, 2, 3, 5, 8, 13, 21, ?)</option>
<option value="tshirt">T-Shirt (XS, S, M, L, XL, XXL, ?)</option>
<option value="linear">Linear (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ?)</option>
</select>
</div>
<button class="btn-primary" type="submit">Create Room</button>
</form>
<div class="divider" style="margin: 1.5rem 0"></div>
<form method="POST" action="/rooms/join" class="auth-form">
<div class="form-group">
<label class="form-label">Or Join Existing</label>
<input class="form-input" type="text" name="code" placeholder="Room code" required />
</div>
<button class="btn-primary" type="submit" style="background: var(--bg-surface); border: 1px solid var(--border);">Join Room</button>
</form>
</div>
</div>
+316
View File
@@ -0,0 +1,316 @@
{{define "stories-panel"}}
<div class="stories-panel" id="stories-panel">
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
"
>
<h3 class="panel-title">Stories</h3>
<div class="sse-indicator">
<span class="sse-dot"></span>
<span>Live</span>
</div>
</div>
<div class="stories-list" id="stories-list">
{{template "stories-list" .}}
</div>
</div>
{{end}}
{{define "stories-list"}} {{range .Stories}} {{$isActive := eq (derefInt
$.Room.ActiveStoryID) .ID}}
<div
class="story-card {{if .Voted}}story-revealed{{end}} {{if $isActive}}story-active{{end}}"
id="story-{{.ID}}"
>
<div class="story-header">
<span class="story-title">{{.Title}}</span>
<div class="story-actions">
{{if $isActive}}
<span class="story-badge story-badge-active">Active</span>
{{end}} {{if $.IsOwner}} {{if .Voted}}
<form
method="POST"
action="/rooms/{{$.Room.ID}}/stories/{{.ID}}/unreveal"
hx-post="/rooms/{{$.Room.ID}}/stories/{{.ID}}/unreveal"
hx-target="#stories-panel"
hx-swap="outerHTML"
class="story-action-form"
>
<button
type="submit"
class="story-action-btn story-action-btn-toggle"
title="Hide votes"
aria-label="Hide votes"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M10.733 5.076A10.744 10.744 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.673 2.68"
/>
<path
d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"
/>
<line x1="2" y1="2" x2="22" y2="22" />
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
</svg>
</button>
</form>
{{else}}
<form
method="POST"
action="/rooms/{{$.Room.ID}}/reveal"
hx-post="/rooms/{{$.Room.ID}}/reveal"
hx-target="#stories-panel"
hx-swap="outerHTML"
class="story-action-form"
>
<input type="hidden" name="story_id" value="{{.ID}}" />
<button
type="submit"
class="story-action-btn story-action-btn-toggle"
title="Reveal votes"
aria-label="Reveal votes"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</form>
{{end}} {{if not $isActive}}
<form
method="POST"
action="/rooms/{{$.Room.ID}}/active"
hx-post="/rooms/{{$.Room.ID}}/active"
hx-target="#stories-panel"
hx-swap="outerHTML"
class="story-action-form"
>
<input type="hidden" name="story_id" value="{{.ID}}" />
<button
type="submit"
class="story-action-btn story-action-btn-activate"
title="Set active"
aria-label="Set active"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="10 8 16 12 10 16 10 8" />
<path d="M4 6v12" />
</svg>
</button>
</form>
{{end}} {{if $.IsOwner}}
<form
method="POST"
action="/rooms/{{$.Room.ID}}/stories/{{.ID}}/reset"
hx-post="/rooms/{{$.Room.ID}}/stories/{{.ID}}/reset"
hx-target="#stories-panel"
hx-swap="outerHTML"
class="story-action-form"
>
<button
type="submit"
class="story-action-btn story-action-btn-reset"
title="Reset votes"
aria-label="Reset votes"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 2v6h-6" />
<path d="M3 12a9 9 0 0 1 15.55-6.36L21 8" />
<path d="M3 22v-6h6" />
<path d="M21 12a9 9 0 0 1-15.55 6.36L3 16" />
</svg>
</button>
</form>
<button
type="button"
class="story-action-btn"
hx-get="/rooms/{{$.Room.ID}}/stories/{{.ID}}/edit"
hx-target="#modal-container"
hx-swap="innerHTML"
title="Rename"
aria-label="Rename story"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 20h9" />
<path
d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4Z"
/>
</svg>
</button>
<button
type="button"
class="story-action-btn story-action-delete"
hx-post="/rooms/{{$.Room.ID}}/stories/{{.ID}}/delete"
hx-target="#stories-panel"
hx-swap="outerHTML"
hx-confirm="Delete this story?"
title="Delete"
aria-label="Delete story"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
{{end}} {{end}}
</div>
</div>
{{if $isActive}}
<div id="vote-area">
{{template "vote-area" (dict "RoomData" $ "Story" .)}}
</div>
{{end}} {{if .Voted}}
<div class="revealed-votes">
{{range (index $.StoryVotes .ID)}}
<div class="revealed-vote">
<span class="revealed-vote-user">{{.Username}}</span>
<span class="revealed-vote-value">{{.Value}}</span>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="empty-state">
<p>No stories yet. Add one to start voting.</p>
</div>
{{end}} {{end}}
{{define "vote-area"}} {{$data := .RoomData}} {{$story := .Story}} {{$userVote
:= index $data.UserVotes $story.ID}}
<div class="vote-form" id="vote-form-{{$story.ID}}">
{{range scaleToOptions $data.Room.Scale}}
<button
type="button"
hx-post="/rooms/{{$data.Room.ID}}/vote"
hx-target="#vote-form-{{$story.ID}}"
hx-swap="outerHTML"
hx-vals='{"story_id":"{{$story.ID}}","value":"{{.}}"}'
class="vote-option {{if eq . $userVote}}vote-selected{{end}}"
>
{{.}}
</button>
{{end}}
</div>
{{end}}
{{define "members-panel"}}
<div class="members-panel" id="members-panel">
<h3 class="panel-title">Members ({{len .Members}})</h3>
<div class="members-list">
{{range .Members}}
<div class="member-row">
<div
class="user-avatar"
style="width: 28px; height: 28px; font-size: 0.7rem"
>
{{slice .Username 0 1}}
</div>
<span class="member-name">{{.Username}}</span>
{{if .HasVoted}}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="var(--success-text)"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
style="margin-left: auto"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{define "sse-script"}}
<script>
document.addEventListener("htmx:sseOpen", function() {
document.body.classList.add("sse-connected");
});
document.addEventListener("htmx:sseClose", function() {
document.body.classList.remove("sse-connected");
});
document.addEventListener("htmx:sseError", function() {
document.body.classList.remove("sse-connected");
});
</script>
{{end}}
+34
View File
@@ -0,0 +1,34 @@
<div class="modal-overlay" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>Rename Story</h2>
<button
class="modal-close"
onclick="this.closest('.modal-overlay').remove()"
>
×
</button>
</div>
<form
method="POST"
action="/rooms/{{.RoomID}}/stories/{{.Story.ID}}/rename"
hx-post="/rooms/{{.RoomID}}/stories/{{.Story.ID}}/rename"
hx-target="#stories-panel"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) document.getElementById('modal-container').innerHTML = ''"
class="auth-form"
>
<div class="form-group">
<label class="form-label">Story Title</label>
<input
class="form-input"
type="text"
name="title"
value="{{.Story.Title}}"
required
/>
</div>
<button class="btn-primary" type="submit">Save Changes</button>
</form>
</div>
</div>
+34
View File
@@ -0,0 +1,34 @@
<div class="modal-overlay" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>Add Story</h2>
<button
class="modal-close"
onclick="this.closest('.modal-overlay').remove()"
>
×
</button>
</div>
<form
method="POST"
action="/rooms/{{.ID}}/stories"
hx-post="/rooms/{{.ID}}/stories"
hx-target="#stories-panel"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) document.getElementById('modal-container').innerHTML = ''"
class="auth-form"
>
<div class="form-group">
<label class="form-label">Story Title</label>
<input
class="form-input"
type="text"
name="title"
placeholder="e.g. As a user..."
required
/>
</div>
<button class="btn-primary" type="submit">Add Story</button>
</form>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
{{$userVote := .UserVote}} {{$roomID := .RoomID}} {{$storyID := .Story.ID}}
<div class="vote-form" id="vote-form-{{$storyID}}">
{{range scaleToOptions .Scale}}
<button
type="button"
hx-post="/rooms/{{$roomID}}/vote"
hx-target="#vote-form-{{$storyID}}"
hx-swap="outerHTML"
hx-vals='{"story_id":"{{$storyID}}","value":"{{.}}"}'
class="vote-option {{if eq . $userVote}}vote-selected{{end}}"
>
{{.}}
</button>
{{end}}
</div>