diff --git a/api/handlers.go b/api/handlers.go index bcbb67d..762070b 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -13,13 +13,6 @@ import ( "sprintpadawan/lib" ) -type PingResponse struct { - Status string `json:"status"` - Message string `json:"message"` -} - -var templates *template.Template - type contextKey string const userKey contextKey = "user" @@ -30,11 +23,14 @@ type sseClient struct { } var ( - // roomClients maps roomID → list of connected SSE clients roomClients = make(map[int][]*sseClient) clientsMu sync.Mutex ) +var templates *template.Template + +// ==================== HELPERS ==================== + func addSSEClient(roomID, userID int, ch chan string) { clientsMu.Lock() defer clientsMu.Unlock() @@ -56,13 +52,13 @@ func removeSSEClient(roomID int, ch chan string) { } } -func broadcast(roomID int, message string) { +func broadcast(roomID int, event string) { clientsMu.Lock() defer clientsMu.Unlock() alive := roomClients[roomID][:0] for _, c := range roomClients[roomID] { select { - case c.ch <- message: + case c.ch <- event: alive = append(alive, c) default: // drop dead client @@ -71,7 +67,6 @@ func broadcast(roomID int, message string) { roomClients[roomID] = alive } -// GetConnectedUserIDs returns deduplicated user IDs connected to a room func GetConnectedUserIDs(roomID int) []int { clientsMu.Lock() defer clientsMu.Unlock() @@ -86,18 +81,6 @@ func GetConnectedUserIDs(roomID int) []int { return ids } -func init() { - templates = template.Must(template.New("").Funcs(template.FuncMap{ - "scaleToOptions": scaleToOptions, - "derefInt": func(i *int) int { - if i == nil { - return 0 - } - return *i - }, - }).ParseGlob(filepath.Join("templates", "*.html"))) -} - func scaleToOptions(scale string) []string { switch scale { case "fibonacci": @@ -111,6 +94,134 @@ func scaleToOptions(scale string) []string { } } +func getRoomID(r *http.Request) int { + return getPathInt(r, "id") +} + +func getPathInt(r *http.Request, key string) int { + var id int + fmt.Sscanf(r.PathValue(key), "%d", &id) + return id +} + +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 +} + +// Builds data used by both full page and all partials +func buildRoomData(room *lib.Room, user *lib.User) RoomData { + members, _ := lib.GetRoomMembers(room.ID) + stories, _ := lib.GetStoriesForRoom(room.ID) + connectedIDs := GetConnectedUserIDs(room.ID) + connectedMap := make(map[int]bool) + for _, cid := range connectedIDs { + connectedMap[cid] = true + } + connectedMap[user.ID] = true + + var activeVotes []lib.Vote + if room.ActiveStoryID != nil { + activeVotes, _ = lib.GetVotesForStory(*room.ActiveStoryID) + } + + var memberViews []MemberView + for _, m := range members { + if !connectedMap[m.ID] { + continue + } + hasVoted := false + for _, v := range activeVotes { + if v.UserID == m.ID { + hasVoted = true + break + } + } + memberViews = append(memberViews, MemberView{ + Username: m.Username, + HasVoted: hasVoted, + ID: m.ID, + }) + } + + userVotes := make(map[int]string) + storyVotes := make(map[int][]lib.VoteView) + for _, s := range stories { + votes, _ := lib.GetVotesForStory(s.ID) + for _, v := range votes { + if v.UserID == user.ID { + userVotes[s.ID] = v.Value + } + } + if s.Voted { + vv, _ := lib.GetVotesWithUsernames(s.ID) + storyVotes[s.ID] = vv + } + } + + return RoomData{ + Room: room, + User: user, + Members: memberViews, + Stories: stories, + IsOwner: room.OwnerID == user.ID, + UserVotes: userVotes, + StoryVotes: storyVotes, + } +} + +func init() { + 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 + }, + }).ParseGlob(filepath.Join("templates", "*.html"))) +} + func SetupRoutes(mux *http.ServeMux) { mux.HandleFunc("/login", handleLogin) mux.HandleFunc("/register", handleRegister) @@ -121,6 +232,13 @@ func SetupRoutes(mux *http.ServeMux) { mux.HandleFunc("/rooms/create", requireAuth(handleCreateRoom)) mux.HandleFunc("/rooms/join", requireAuth(handleJoinRoom)) mux.HandleFunc("/rooms/{id}", requireAuth(handleRoom)) + + // HTMX partials + 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)) + + // Actions mux.HandleFunc("/rooms/{id}/stories/new", requireAuth(handleNewStoryForm)) mux.HandleFunc("/rooms/{id}/stories", requireAuth(handleAddStory)) mux.HandleFunc("/rooms/{id}/active", requireAuth(handleSetActiveStory)) @@ -133,6 +251,38 @@ func SetupRoutes(mux *http.ServeMux) { mux.HandleFunc("/sse/{room_id}", requireAuth(handleSSE)) } +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 + } + + renderTemplate(w, "stories-panel", buildRoomData(room, user)) +} + +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 + } + + renderTemplate(w, "members-panel", buildRoomData(room, user)) +} + func requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_token") @@ -150,7 +300,6 @@ func requireAuth(next http.HandlerFunc) http.HandlerFunc { } } -// isLoggedIn checks if the request has a valid session func isLoggedIn(r *http.Request) bool { cookie, err := r.Cookie("session_token") if err != nil { @@ -160,6 +309,8 @@ func isLoggedIn(r *http.Request) bool { return err == nil && user != nil } +// ==================== AUTH HANDLERS ==================== + func handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { if isLoggedIn(r) { @@ -233,6 +384,8 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) } +// ==================== DASHBOARD ==================== + func handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -241,37 +394,43 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(userKey).(*lib.User) rooms, _ := lib.GetRoomsForUser(user.ID) w.Header().Set("Content-Type", "text/html; charset=utf-8") + type RoomView struct { - ID int - Name string - Code string - Scale string - IsOwner bool + ID int + Name string + Code string + Scale string + IsOwner bool MemberCount int } + data := struct { *lib.User Rooms []RoomView }{ User: user, } + for _, room := range rooms { members, _ := lib.GetRoomMembers(room.ID) data.Rooms = append(data.Rooms, RoomView{ ID: room.ID, Name: room.Name, - Code: room.Code, - Scale: room.Scale, - IsOwner: room.OwnerID == user.ID, + Code: room.Code, + Scale: room.Scale, + IsOwner: room.OwnerID == user.ID, MemberCount: len(members), }) } + if err := templates.ExecuteTemplate(w, "index.html", data); err != nil { log.Printf("template error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) } } +// ==================== ROOM CREATION / JOIN ==================== + 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) @@ -303,167 +462,129 @@ func handleJoinRoom(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) } +// ==================== MAIN ROOM & PARTIALS ==================== + func handleRoom(w http.ResponseWriter, r *http.Request) { + roomID := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) - var id int - fmt.Sscanf(r.PathValue("id"), "%d", &id) - room, err := lib.GetRoomByID(id) + room, err := lib.GetRoomByID(roomID) if err != nil { http.NotFound(w, r) return } lib.AddUserToRoom(room.ID, user.ID) - members, _ := lib.GetRoomMembers(room.ID) - stories, _ := lib.GetStoriesForRoom(room.ID) - // Add HasVoted logic - type MemberView struct { - Username string - HasVoted bool + data := buildRoomData(room, user) + renderTemplate(w, "room.html", data) +} + +func handlePartialStories(w http.ResponseWriter, r *http.Request) { + roomID := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + renderRoomStories(w, roomID, user) +} + +func handlePartialMembers(w http.ResponseWriter, r *http.Request) { + roomID := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + renderRoomMembers(w, roomID, user) +} + +func handlePartialVoteArea(w http.ResponseWriter, r *http.Request) { + roomID := getRoomID(r) + 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 } - var activeVotes []lib.Vote - if room.ActiveStoryID != nil { - activeVotes, _ = lib.GetVotesForStory(*room.ActiveStoryID) - } - - // Filter to only connected members - connectedIDs := GetConnectedUserIDs(room.ID) - connectedMap := make(map[int]bool) - for _, cid := range connectedIDs { - connectedMap[cid] = true - } - // The current user loading the page will immediately connect to SSE - connectedMap[user.ID] = true - - var memberViews []MemberView - for _, m := range members { - // Only include connected users - if !connectedMap[m.ID] { - continue - } - - hasVoted := false - for _, v := range activeVotes { - if v.UserID == m.ID { - hasVoted = true - break - } - } - memberViews = append(memberViews, MemberView{ - Username: m.Username, - HasVoted: hasVoted, - }) - } - - userVotes := make(map[int]string) - storyVotes := make(map[int][]lib.VoteView) - for _, s := range stories { - votes, _ := lib.GetVotesForStory(s.ID) - for _, v := range votes { - if v.UserID == user.ID { - userVotes[s.ID] = v.Value - } - } - // For revealed stories, get votes with usernames - if s.Voted { - vv, _ := lib.GetVotesWithUsernames(s.ID) - storyVotes[s.ID] = vv - } + story, err := lib.GetStoryByID(*room.ActiveStoryID) + if err != nil { + http.Error(w, "story not found", http.StatusNotFound) + return } data := struct { - *lib.Room - User *lib.User - Members []MemberView - Stories []lib.Story - IsOwner bool - UserVotes map[int]string - StoryVotes map[int][]lib.VoteView + RoomData RoomData + Story lib.Story }{ - Room: room, - User: user, - Members: memberViews, - Stories: stories, - IsOwner: room.OwnerID == user.ID, - UserVotes: userVotes, - StoryVotes: storyVotes, - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := templates.ExecuteTemplate(w, "room.html", data); err != nil { - log.Printf("template error: %v", err) + RoomData: buildRoomData(room, user), + Story: *story, } + renderTemplate(w, "vote-area", data) } +// ==================== STORY & VOTING ACTIONS ==================== + func handleNewStoryForm(w http.ResponseWriter, r *http.Request) { - var id int - fmt.Sscanf(r.PathValue("id"), "%d", &id) - room, _ := lib.GetRoomByID(id) - if err := templates.ExecuteTemplate(w, "story_form.html", room); err != nil { - log.Printf("template error: %v", err) - } + roomID := getRoomID(r) + room, _ := lib.GetRoomByID(roomID) + renderTemplate(w, "story_form.html", room) } func handleAddStory(w http.ResponseWriter, r *http.Request) { - var id int - fmt.Sscanf(r.PathValue("id"), "%d", &id) + id := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) title := r.FormValue("title") _, err := lib.CreateStory(id, title) if 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 } - broadcast(id, "refresh") http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleSetActiveStory(w http.ResponseWriter, r *http.Request) { - var id int - fmt.Sscanf(r.PathValue("id"), "%d", &id) - storyID := r.FormValue("story_id") + id := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) var sid int - fmt.Sscanf(storyID, "%d", &sid) + fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) - // Get the current room to find old active story room, _ := lib.GetRoomByID(id) - - // Unreveal and clear votes on the old active story (if any) if room.ActiveStoryID != nil { lib.UnrevealStory(*room.ActiveStoryID) lib.ClearVotesForStory(*room.ActiveStoryID) } - - // Unreveal and clear votes on the new active story lib.UnrevealStory(sid) lib.ClearVotesForStory(sid) - - // Set the new active story lib.SetActiveStory(id, sid) - broadcast(id, "refresh") + 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) { - var id int - fmt.Sscanf(r.PathValue("id"), "%d", &id) + id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) value := r.FormValue("value") - storyID := r.FormValue("story_id") var sid int - fmt.Sscanf(storyID, "%d", &sid) + fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) + lib.VoteOnStory(sid, user.ID, value) - broadcast(id, "members") // Updates member panel with checkmarks + broadcast(id, "members") + if story, err := lib.GetStoryByID(sid); err == nil && story.Voted { + broadcast(id, "stories") + } if r.Header.Get("HX-Request") == "true" { room, _ := lib.GetRoomByID(id) - stories, _ := lib.GetStoriesForRoom(id) - var st lib.Story - for _, s := range stories { - if s.ID == sid { - st = s - break - } - } + story, _ := lib.GetStoryByID(sid) tmplData := struct { RoomID int Story lib.Story @@ -471,11 +592,11 @@ func handleVote(w http.ResponseWriter, r *http.Request) { UserVote string }{ RoomID: id, - Story: st, + Story: *story, Scale: room.Scale, UserVote: value, } - templates.ExecuteTemplate(w, "vote_form.html", tmplData) + renderTemplate(w, "vote_form.html", tmplData) return } @@ -483,74 +604,81 @@ func handleVote(w http.ResponseWriter, r *http.Request) { } func handleReveal(w http.ResponseWriter, r *http.Request) { - var id int - fmt.Sscanf(r.PathValue("id"), "%d", &id) - storyID := r.FormValue("story_id") + id := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) var sid int - fmt.Sscanf(storyID, "%d", &sid) + fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) lib.RevealVotes(sid) - broadcast(id, "refresh") + 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) { - var id, sid int - fmt.Sscanf(r.PathValue("id"), "%d", &id) + id := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + var sid int fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) lib.UnrevealStory(sid) - broadcast(id, "refresh") + 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) { - var id, sid int - fmt.Sscanf(r.PathValue("id"), "%d", &id) - fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) - stories, _ := lib.GetStoriesForRoom(id) - var story lib.Story - for _, s := range stories { - if s.ID == sid { - story = s - break - } - } - tmplData := struct { + roomID := getRoomID(r) + var storyID int + fmt.Sscanf(r.PathValue("story_id"), "%d", &storyID) + story, _ := lib.GetStoryByID(storyID) + data := struct { RoomID int Story lib.Story }{ - RoomID: id, - Story: story, - } - if err := templates.ExecuteTemplate(w, "story_edit.html", tmplData); err != nil { - log.Printf("template error: %v", err) + RoomID: roomID, + Story: *story, } + renderTemplate(w, "story_edit.html", data) } func handleRenameStory(w http.ResponseWriter, r *http.Request) { - var id, sid int - fmt.Sscanf(r.PathValue("id"), "%d", &id) + id := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + var sid int fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) title := r.FormValue("title") lib.RenameStory(sid, title) - broadcast(id, "refresh") + 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) { - var id, sid int - fmt.Sscanf(r.PathValue("id"), "%d", &id) + id := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + var sid int fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) lib.DeleteStory(sid) - broadcast(id, "refresh") - // Return empty HTML so the story-card is removed from the DOM - w.Header().Set("Content-Type", "text/html") - w.Write([]byte("")) + broadcast(id, "stories") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } -func handleSSE(w http.ResponseWriter, r *http.Request) { - var roomID int - fmt.Sscanf(r.PathValue("room_id"), "%d", &roomID) +// ==================== SSE ==================== +func handleSSE(w http.ResponseWriter, r *http.Request) { + roomID := getPathInt(r, "room_id") user, ok := r.Context().Value(userKey).(*lib.User) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) @@ -559,30 +687,36 @@ func handleSSE(w http.ResponseWriter, r *http.Request) { ch := make(chan string, 10) addSSEClient(roomID, user.ID, ch) - - // Broadcast members to other clients so they see the new member - go broadcast(roomID, "members") + broadcast(roomID, "members") // notify others of new connection w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } - 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 members so others see member leave - go broadcast(roomID, "members") + broadcast(roomID, "members") return - case msg := <-ch: - fmt.Fprintf(w, "event: room-%d\ndata: %s\n\n", roomID, msg) + 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/lib/story.go b/lib/story.go index 081f57c..fa15d12 100644 --- a/lib/story.go +++ b/lib/story.go @@ -6,11 +6,11 @@ import ( ) type Story struct { - ID int - RoomID int - Title string - Points *int - Voted bool + ID int + RoomID int + Title string + Points *int + Voted bool } type Vote struct { @@ -168,3 +168,20 @@ func tshirtToPoints(s string) float64 { 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/static/styles/main.css b/static/styles/main.css index 4470f50..b3d548e 100644 --- a/static/styles/main.css +++ b/static/styles/main.css @@ -983,6 +983,10 @@ footer a:hover { min-height: 400px; } +.room-stream { + display: none; +} + .stories-panel, .members-panel { background: var(--bg-surface); @@ -1238,11 +1242,11 @@ footer a:hover { gap: 0.375rem; font-size: 0.75rem; font-weight: 500; - color: var(--success-text); + color: var(--text-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-full); - background: var(--success-bg); - border: 1px solid var(--success-border); + background: var(--bg-surface-hover); + border: 1px solid var(--border); } .mobile-menu-btn { @@ -1265,6 +1269,16 @@ footer a:hover { 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; } diff --git a/templates/room.html b/templates/room.html index 74e996e..7b8f117 100644 --- a/templates/room.html +++ b/templates/room.html @@ -1,21 +1,35 @@ +{{define "room.html"}}
-