diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..ba5506b --- /dev/null +++ b/api/auth.go @@ -0,0 +1,80 @@ +package api + +import ( + "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, _ := lib.CreateSession(user.ID) + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: sessionID, + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + 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 { + lib.DeleteSession(cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + Path: "/", + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/api/dashboard.go b/api/dashboard.go new file mode 100644 index 0000000..92eca82 --- /dev/null +++ b/api/dashboard.go @@ -0,0 +1,50 @@ +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, _ := lib.GetRoomsForUser(user.ID) + 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, + 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) + } +} diff --git a/api/handlers.go b/api/handlers.go deleted file mode 100644 index 762070b..0000000 --- a/api/handlers.go +++ /dev/null @@ -1,723 +0,0 @@ -package api - -import ( - "context" - "fmt" - "html/template" - "log" - "net/http" - "path/filepath" - "sync" - "time" - - "sprintpadawan/lib" -) - -type contextKey string - -const userKey contextKey = "user" - -type sseClient struct { - ch chan string - userID int -} - -var ( - 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() - 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.Lock() - defer clientsMu.Unlock() - alive := roomClients[roomID][:0] - for _, c := range roomClients[roomID] { - select { - case c.ch <- event: - alive = append(alive, c) - default: - // drop dead client - } - } - roomClients[roomID] = alive -} - -func GetConnectedUserIDs(roomID int) []int { - clientsMu.Lock() - defer clientsMu.Unlock() - 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 { - 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) - 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)) - - // 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)) - 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}/unreveal", requireAuth(handleUnrevealStory)) - 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") - 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 -} - -// ==================== AUTH HANDLERS ==================== - -func handleLogin(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - if isLoggedIn(r) { - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - templates.ExecuteTemplate(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) { - templates.ExecuteTemplate(w, "login.html", map[string]string{"Error": "Invalid credentials"}) - return - } - - sessionID, _ := lib.CreateSession(user.ID) - http.SetCookie(w, &http.Cookie{ - Name: "session_token", - Value: sessionID, - Expires: time.Now().Add(24 * time.Hour), - HttpOnly: true, - 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 - } - templates.ExecuteTemplate(w, "register.html", nil) - return - } - - username := r.FormValue("username") - password := r.FormValue("password") - confirm := r.FormValue("confirm_password") - - if password != confirm { - templates.ExecuteTemplate(w, "register.html", map[string]string{"Error": "Passwords do not match"}) - return - } - - err := lib.CreateUser(username, password) - if err != nil { - templates.ExecuteTemplate(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 { - lib.DeleteSession(cookie.Value) - } - http.SetCookie(w, &http.Cookie{ - Name: "session_token", - Value: "", - Expires: time.Now().Add(-1 * time.Hour), - Path: "/", - }) - http.Redirect(w, r, "/login", http.StatusSeeOther) -} - -// ==================== DASHBOARD ==================== - -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, _ := 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 - 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, - 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) - } -} - -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 - } - lib.AddUserToRoom(room.ID, user.ID) - 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) - room, err := lib.GetRoomByID(roomID) - if err != nil { - http.NotFound(w, r) - return - } - lib.AddUserToRoom(room.ID, user.ID) - - 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 - } - - story, err := lib.GetStoryByID(*room.ActiveStoryID) - if err != nil { - http.Error(w, "story not found", http.StatusNotFound) - return - } - - data := struct { - RoomData RoomData - Story lib.Story - }{ - RoomData: buildRoomData(room, user), - Story: *story, - } - renderTemplate(w, "vote-area", data) -} - -// ==================== STORY & VOTING ACTIONS ==================== - -func handleNewStoryForm(w http.ResponseWriter, r *http.Request) { - roomID := getRoomID(r) - room, _ := lib.GetRoomByID(roomID) - renderTemplate(w, "story_form.html", room) -} - -func handleAddStory(w http.ResponseWriter, r *http.Request) { - 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 - } - http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) -} - -func handleSetActiveStory(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) - user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) - - room, _ := lib.GetRoomByID(id) - if room.ActiveStoryID != nil { - lib.UnrevealStory(*room.ActiveStoryID) - lib.ClearVotesForStory(*room.ActiveStoryID) - } - lib.UnrevealStory(sid) - lib.ClearVotesForStory(sid) - lib.SetActiveStory(id, sid) - - 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 := getRoomID(r) - user := r.Context().Value(userKey).(*lib.User) - value := r.FormValue("value") - var sid int - fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) - - lib.VoteOnStory(sid, user.ID, value) - 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) - story, _ := lib.GetStoryByID(sid) - 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 := getRoomID(r) - user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) - lib.RevealVotes(sid) - 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 := 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, "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 := 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: roomID, - Story: *story, - } - renderTemplate(w, "story_edit.html", data) -} - -func handleRenameStory(w http.ResponseWriter, r *http.Request) { - 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, "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 := 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, "stories") - if isHTMX(r) { - renderRoomStories(w, id, user) - return - } - http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) -} - -// ==================== 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) - return - } - - ch := make(chan string, 10) - addSSEClient(roomID, user.ID, ch) - 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("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(roomID, "members") - return - case <-heartbeat.C: - fmt.Fprint(w, ": keep-alive\n\n") - flusher.Flush() - case event := <-ch: - fmt.Fprintf(w, "event: %s\ndata: true\n\n", event) - flusher.Flush() - } - } -} diff --git a/api/rooms.go b/api/rooms.go new file mode 100644 index 0000000..987d3d8 --- /dev/null +++ b/api/rooms.go @@ -0,0 +1,94 @@ +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 + } + lib.AddUserToRoom(room.ID, user.ID) + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) +} + +func handleRoom(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.NotFound(w, r) + return + } + lib.AddUserToRoom(room.ID, user.ID) + + renderTemplate(w, "room.html", buildRoomData(room, user)) +} + +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 + } + + story, err := lib.GetStoryByID(*room.ActiveStoryID) + if err != nil { + http.Error(w, "story not found", http.StatusNotFound) + return + } + + data := struct { + RoomData RoomData + Story lib.Story + }{ + RoomData: buildRoomData(room, user), + Story: *story, + } + renderTemplate(w, "vote-area", data) +} diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..5143944 --- /dev/null +++ b/api/routes.go @@ -0,0 +1,31 @@ +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}/partial/stories", requireAuth(handlePartialStories)) + mux.HandleFunc("/rooms/{id}/partial/members", requireAuth(handlePartialMembers)) + mux.HandleFunc("/rooms/{id}/partial/vote-area", requireAuth(handlePartialVoteArea)) + + mux.HandleFunc("/rooms/{id}/stories/new", requireAuth(handleNewStoryForm)) + mux.HandleFunc("/rooms/{id}/stories", requireAuth(handleAddStory)) + mux.HandleFunc("/rooms/{id}/active", requireAuth(handleSetActiveStory)) + mux.HandleFunc("/rooms/{id}/vote", requireAuth(handleVote)) + mux.HandleFunc("/rooms/{id}/reveal", requireAuth(handleReveal)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/edit", requireAuth(handleEditStoryForm)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/rename", requireAuth(handleRenameStory)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/delete", requireAuth(handleDeleteStory)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/reset", requireAuth(handleResetStory)) + mux.HandleFunc("/rooms/{id}/stories/{story_id}/unreveal", requireAuth(handleUnrevealStory)) + mux.HandleFunc("/sse/{room_id}", requireAuth(handleSSE)) +} diff --git a/api/sse.go b/api/sse.go new file mode 100644 index 0000000..4313388 --- /dev/null +++ b/api/sse.go @@ -0,0 +1,54 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "sprintpadawan/lib" +) + +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) + return + } + + ch := make(chan string, 10) + addSSEClient(roomID, user.ID, ch) + broadcast(roomID, "members") + + 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 + } + notify := r.Context().Done() + heartbeat := time.NewTicker(25 * time.Second) + defer heartbeat.Stop() + + fmt.Fprint(w, ": connected\n\n") + flusher.Flush() + + for { + select { + case <-notify: + removeSSEClient(roomID, ch) + broadcast(roomID, "members") + return + case <-heartbeat.C: + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + case event := <-ch: + fmt.Fprintf(w, "event: %s\ndata: true\n\n", event) + flusher.Flush() + } + } +} diff --git a/api/stories.go b/api/stories.go new file mode 100644 index 0000000..610c7f2 --- /dev/null +++ b/api/stories.go @@ -0,0 +1,177 @@ +package api + +import ( + "fmt" + "log" + "net/http" + + "sprintpadawan/lib" +) + +func handleNewStoryForm(w http.ResponseWriter, r *http.Request) { + roomID := getRoomID(r) + room, _ := lib.GetRoomByID(roomID) + renderTemplate(w, "story_form.html", room) +} + +func handleAddStory(w http.ResponseWriter, r *http.Request) { + id := getRoomID(r) + 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 := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + var sid int + fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) + + lib.SetActiveStory(id, sid) + + 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 := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + var sid int + fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) + + lib.UnrevealStory(sid) + lib.ClearVotesForStory(sid) + + 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 := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + value := r.FormValue("value") + var sid int + fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) + + lib.VoteOnStory(sid, user.ID, value) + broadcast(id, "members") + story, storyErr := lib.GetStoryByID(sid) + if storyErr == nil && story.Voted { + broadcast(id, "stories") + } + + if isHTMX(r) { + room, _ := lib.GetRoomByID(id) + if storyErr != nil { + http.Error(w, "story 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 := getRoomID(r) + user := r.Context().Value(userKey).(*lib.User) + var sid int + fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) + lib.RevealVotes(sid) + 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 := 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, "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 := 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: roomID, + Story: *story, + } + renderTemplate(w, "story_edit.html", data) +} + +func handleRenameStory(w http.ResponseWriter, r *http.Request) { + 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, "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 := 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, "stories") + if isHTMX(r) { + renderRoomStories(w, id, user) + return + } + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) +} diff --git a/api/templates.go b/api/templates.go new file mode 100644 index 0000000..4db0040 --- /dev/null +++ b/api/templates.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "path/filepath" + + "sprintpadawan/lib" +) + +var templates *template.Template + +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 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") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + user, err := lib.GetUserFromSession(cookie.Value) + if err != nil || user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), userKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} + +func isLoggedIn(r *http.Request) bool { + cookie, err := r.Cookie("session_token") + if err != nil { + return false + } + user, err := lib.GetUserFromSession(cookie.Value) + return err == nil && user != nil +} diff --git a/api/types.go b/api/types.go new file mode 100644 index 0000000..7802726 --- /dev/null +++ b/api/types.go @@ -0,0 +1,172 @@ +package api + +import ( + "fmt" + "net/http" + "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.Mutex +) + +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.Lock() + defer clientsMu.Unlock() + alive := roomClients[roomID][:0] + for _, c := range roomClients[roomID] { + select { + case c.ch <- event: + alive = append(alive, c) + default: + // drop dead client + } + } + roomClients[roomID] = alive +} + +func GetConnectedUserIDs(roomID int) []int { + clientsMu.Lock() + defer clientsMu.Unlock() + 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 { + return getPathInt(r, "id") +} + +func getPathInt(r *http.Request, key string) int { + var id int + fmt.Sscanf(r.PathValue(key), "%d", &id) + return id +} + +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, + } +} diff --git a/static/styles/main.css b/static/styles/main.css index b3d548e..623c280 100644 --- a/static/styles/main.css +++ b/static/styles/main.css @@ -526,34 +526,21 @@ footer a:hover { /* Dashboard */ .app-shell { - display: grid; - grid-template-columns: 260px 1fr; + display: block; height: 100vh; width: 100%; overflow: hidden; background: #09090b; } -/* sidebar */ -.sidebar { - background: #09090b; - border-right: 1px solid #18181b; - display: flex; - flex-direction: column; - padding: 1.5rem 1rem; - overflow-y: auto; - z-index: 10; -} - -.sidebar-logo { +.brand-mark { display: flex; align-items: center; gap: 0.75rem; - padding: 0.5rem 0.75rem; - margin-bottom: 2.5rem; + text-decoration: none; } -.sidebar-logo .logo-icon { +.brand-mark .logo-icon { width: 32px; height: 32px; background: linear-gradient(135deg, var(--accent), #4f46e5); @@ -568,7 +555,7 @@ footer a:hover { flex-shrink: 0; } -.sidebar-logo .logo-text { +.brand-mark .logo-text { font-family: var(--font-heading); font-size: 1.15rem; font-weight: 600; @@ -576,56 +563,10 @@ footer a:hover { color: var(--text-primary); } -.sidebar-logo .logo-text span { +.brand-mark .logo-text span { color: var(--accent); } -/* Nav */ - display: flex; - flex-direction: column; - gap: 0.25rem; - flex: 1; -} - -.nav-item { - display: flex; - align-items: center; - gap: 0.65rem; - padding: 0.6rem 0.75rem; - border-radius: var(--radius-md); - color: var(--text-secondary); - text-decoration: none; - font-size: 0.9rem; - font-weight: 500; - transition: all 0.15s; - border: 1px solid transparent; -} - -.nav-item:hover { - background: #18181b; - color: var(--text-primary); -} - -.nav-item.active { - background: #18181b; - color: var(--text-primary); - border-color: #27272a; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); -} - -.nav-item svg { - width: 16px; - height: 16px; - flex-shrink: 0; -} - -/* sidebar bottom section */ -.sidebar-footer { - margin-top: auto; - padding-top: 1rem; - border-top: 1px solid var(--border); -} - .user-row { display: flex; align-items: center; @@ -699,6 +640,7 @@ footer a:hover { flex-direction: column; overflow-y: auto; background: #0d0d12; + min-height: 100vh; } .topbar { @@ -707,11 +649,24 @@ footer a:hover { justify-content: space-between; padding: 1rem 2.5rem; border-bottom: 1px solid #18181b; - background: #0d0d12; + background: rgba(13, 13, 18, 0.94); + backdrop-filter: blur(16px); flex-shrink: 0; position: sticky; top: 0; z-index: 5; + gap: 1rem; +} + +.topbar-brand, +.topbar-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.topbar-brand { + min-width: 0; } .topbar-title { @@ -720,11 +675,76 @@ footer a:hover { color: var(--text-primary); } +.room-title { + max-width: 24rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar-btn { + width: auto; + padding: 0.5rem 1rem; +} + +.topbar-user { + display: inline-flex; + align-items: center; + gap: 0.7rem; + min-height: 44px; + padding: 0.3rem 0.85rem 0.3rem 0.3rem; + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.topbar-user-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + line-height: 1; +} + +.topbar-user .user-avatar { + width: 38px; + height: 38px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.04); + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.topbar-link-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0.55rem 0.9rem; + border-radius: var(--radius-full); + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.85rem; + font-family: var(--font); + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.topbar-link-btn:hover { + border-color: rgba(239, 68, 68, 0.4); + color: #f87171; + background: rgba(239, 68, 68, 0.06); +} + .page-content { padding: 2rem; flex: 1; max-width: 900px; width: 100%; + margin: 0 auto; } /* welcome hero */ @@ -1057,6 +1077,10 @@ footer a:hover { flex: 0 0 auto; } +.story-action-form { + margin: 0; +} + .story-btn { display: inline-flex; align-items: center; @@ -1164,11 +1188,11 @@ footer a:hover { display: inline-flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 42px; + height: 42px; padding: 0; - border-radius: var(--radius-sm); - background: transparent; + border-radius: 14px; + background: rgba(255, 255, 255, 0.02); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer; @@ -1176,10 +1200,52 @@ footer a:hover { flex-shrink: 0; } +.story-action-btn svg { + width: 16px; + height: 16px; +} + .story-action-btn:hover { background: var(--bg-surface-hover); color: var(--text-primary); border-color: var(--border-accent); + transform: translateY(-1px); +} + +.story-action-btn-toggle { + color: var(--accent); + border-color: rgba(99, 102, 241, 0.35); + background: rgba(99, 102, 241, 0.08); +} + +.story-action-btn-toggle:hover { + color: #c7d2fe; + border-color: rgba(99, 102, 241, 0.5); + background: rgba(99, 102, 241, 0.16); +} + +.story-action-btn-activate { + color: #f8fafc; + border-color: rgba(148, 163, 184, 0.26); + background: rgba(148, 163, 184, 0.08); +} + +.story-action-btn-activate:hover { + color: #ffffff; + border-color: rgba(148, 163, 184, 0.42); + background: rgba(148, 163, 184, 0.16); +} + +.story-action-btn-reset { + color: var(--success-text); + border-color: rgba(16, 185, 129, 0.26); + background: rgba(16, 185, 129, 0.06); +} + +.story-action-btn-reset:hover { + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.4); + background: rgba(16, 185, 129, 0.14); } .story-action-btn.story-action-delete:hover { @@ -1249,22 +1315,6 @@ footer a:hover { border: 1px solid var(--border); } -.mobile-menu-btn { - display: none; - background: transparent; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 0.25rem; - border-radius: var(--radius-sm); - transition: all 0.2s ease; -} - -.mobile-menu-btn:hover { - color: var(--text-primary); - background: var(--bg-surface-hover); -} - .sse-dot { width: 6px; height: 6px; @@ -1283,55 +1333,32 @@ footer a:hover { animation: pulse 2s ease infinite; } -/* Responsive */ - -.sidebar-backdrop { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); - z-index: 90; - opacity: 0; - transition: opacity 0.3s ease; -} - @media (max-width: 768px) { - .app-shell { - grid-template-columns: 1fr; + .topbar { + padding: 1rem 1.1rem; + align-items: flex-start; + flex-direction: column; } - .mobile-menu-btn { - display: block; + .topbar-brand, + .topbar-actions { + width: 100%; + flex-wrap: wrap; } - .sidebar-backdrop.open { - display: block; - opacity: 1; + .topbar-actions { + justify-content: flex-start; } - .sidebar { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: 280px; - z-index: 100; - transform: translateX(-100%); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - background: var(--bg); /* Opaque background for mobile menu */ - border-right: 1px solid var(--border); - box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + .page-content { + padding: 1.1rem; } - .sidebar.open { - transform: translateX(0); + .room-title { + max-width: 100%; } - .dashboard-grid { - grid-template-columns: 1fr; + .topbar-user { + order: 2; } } diff --git a/templates/index.html b/templates/index.html index f45b489..392eceb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,60 +1,22 @@ -
- - -