From 8f96d7514f36cf92d4501d9ed54f77a1a490a405 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Thu, 5 Mar 2026 22:08:06 +0200 Subject: [PATCH] Update --- .gitignore | 4 + Dockerfile | 2 + README.md | 70 ++-- src/config/config.go | 13 +- src/controllers/page_controller.go | 20 - src/handlers/pages.go | 26 ++ src/handlers/room_api.go | 243 ++++++++++++ src/main.go | 14 +- src/models/room_setup.go | 2 - src/routes/routes.go | 25 +- src/server/router.go | 6 +- src/state/manager.go | 604 +++++++++++++++++++++++++++++ src/state/persistence.go | 77 ++++ src/state/types.go | 151 ++++++++ src/state/utils.go | 83 ++++ src/templates/body-config.html | 127 ------ src/templates/footer.html | 10 - src/templates/header.html | 18 - src/templates/index.html | 149 ++++++- src/templates/room.html | 102 +++++ static/css/styles.css | 295 ++++++++++---- static/js/app.js | 184 --------- static/js/config.js | 260 +++++++++++++ static/js/room.js | 306 +++++++++++++++ 24 files changed, 2303 insertions(+), 488 deletions(-) delete mode 100644 src/controllers/page_controller.go create mode 100644 src/handlers/pages.go create mode 100644 src/handlers/room_api.go create mode 100644 src/state/manager.go create mode 100644 src/state/persistence.go create mode 100644 src/state/types.go create mode 100644 src/state/utils.go delete mode 100644 src/templates/body-config.html delete mode 100644 src/templates/footer.html delete mode 100644 src/templates/header.html create mode 100644 src/templates/room.html delete mode 100644 static/js/app.js create mode 100644 static/js/config.js create mode 100644 static/js/room.js diff --git a/.gitignore b/.gitignore index 3e1c7cb..139ceac 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ Thumbs.db # Environment .env .env.* + + +# Data +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e02b094..2f3a025 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,8 @@ COPY --from=build /out/scrum-solitare /app/scrum-solitare COPY --from=build /app/src/templates /app/src/templates COPY --from=build /app/static /app/static +RUN mkdir -p /app/data && chown -R app:app /app + EXPOSE 8002 ENV PORT=8002 diff --git a/README.md b/README.md index 4441bc6..b560c77 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,40 @@ # Scrum Solitare -Win98-themed Scrum Poker web app scaffold using Go + Gin. +Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time room updates. -## Features +## Highlights -- Gin server with default port `8002` -- Gzip compression enabled -- Cache headers for static `css`, `js`, and image assets -- Template rendering from `src/templates` -- Static file hosting from `static/` -- `/` currently serves a room configuration page (UI only) +- Go backend with layered architecture (`handlers`, `state`, `routes`, `config`) +- Memory-first room state with disk synchronization to JSON files +- Real-time updates via Server-Sent Events (SSE) +- Strict state sanitization: unrevealed votes from other users are never broadcast +- Backend authorization for admin-only actions (`reveal`, `reset`) +- Win98-themed frontend with: + - Config page and card deck preview + - Drag-and-drop card ordering + - Card add/remove with animation completion handling + - Room password option + - Room interface with voting area, participants, and admin controls +- Username persistence through `localStorage` ## Project Layout -- `src/main.go`: Application bootstrap -- `src/config/`: Environment and runtime configuration -- `src/server/`: Gin engine construction and middleware wiring -- `src/routes/`: Route registration -- `src/controllers/`: HTTP handlers/controllers -- `src/middleware/`: Custom Gin middleware -- `src/models/`: Page/view data models -- `src/templates/`: HTML templates (`header`, `body`, `footer`, and `index` composition) -- `static/css/`: Stylesheets -- `static/js/`: Frontend scripts -- `static/img/`: Image assets +- `src/main.go`: app bootstrap +- `src/config/`: environment configuration +- `src/server/`: Gin engine setup +- `src/routes/`: page/api route registration +- `src/handlers/`: HTTP handlers (pages + API + SSE) +- `src/state/`: in-memory room manager, sanitization, persistence +- `src/middleware/`: static cache headers middleware +- `src/models/`: template page data models +- `src/templates/`: HTML templates (`index.html`, `room.html`) +- `static/css/`: styles +- `static/js/`: frontend logic (`config.js`, `room.js`) + +## Environment Variables + +- `PORT`: server port (default `8002`) +- `DATA_PATH`: directory for room JSON files (default `./data`) ## Run Locally @@ -34,22 +45,27 @@ go run ./src Open `http://localhost:8002`. -## Environment Variables - -- `PORT`: Optional server port override (default is `8002`) - ## Docker -Build image: +Build: ```bash docker build -t scrum-solitare . ``` -Run container: +Run: ```bash -docker run --rm -p 8002:8002 scrum-solitare +docker run --rm -p 8002:8002 -e DATA_PATH=/app/data scrum-solitare ``` -Then open `http://localhost:8002`. +## Real-Time Flow (SSE) + +- Client joins room via `POST /api/rooms/:roomID/join` +- Client subscribes to `GET /api/rooms/:roomID/events?participantId=...` +- Server broadcasts sanitized room state updates on critical mutations: + - room creation + - join/leave + - vote cast + - reveal + - reset diff --git a/src/config/config.go b/src/config/config.go index f05717c..96009a9 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -3,7 +3,8 @@ package config import "os" type Config struct { - Port string + Port string + DataPath string } func Load() Config { @@ -12,5 +13,13 @@ func Load() Config { port = "8002" } - return Config{Port: port} + dataPath := os.Getenv("DATA_PATH") + if dataPath == "" { + dataPath = "./data" + } + + return Config{ + Port: port, + DataPath: dataPath, + } } diff --git a/src/controllers/page_controller.go b/src/controllers/page_controller.go deleted file mode 100644 index b0a00f2..0000000 --- a/src/controllers/page_controller.go +++ /dev/null @@ -1,20 +0,0 @@ -package controllers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "scrum-solitare/src/models" -) - -type PageController struct{} - -func NewPageController() *PageController { - return &PageController{} -} - -func (pc *PageController) ShowRoomSetup(c *gin.Context) { - pageData := models.DefaultRoomSetupPageData() - c.HTML(http.StatusOK, "index.html", pageData) -} diff --git a/src/handlers/pages.go b/src/handlers/pages.go new file mode 100644 index 0000000..fc8be3b --- /dev/null +++ b/src/handlers/pages.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "scrum-solitare/src/models" +) + +type PageHandler struct{} + +func NewPageHandler() *PageHandler { + return &PageHandler{} +} + +func (h *PageHandler) ShowConfigPage(c *gin.Context) { + pageData := models.DefaultRoomSetupPageData() + c.HTML(http.StatusOK, "index.html", pageData) +} + +func (h *PageHandler) ShowRoomPage(c *gin.Context) { + c.HTML(http.StatusOK, "room.html", gin.H{ + "RoomID": c.Param("roomID"), + }) +} diff --git a/src/handlers/room_api.go b/src/handlers/room_api.go new file mode 100644 index 0000000..3f2fe72 --- /dev/null +++ b/src/handlers/room_api.go @@ -0,0 +1,243 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "scrum-solitare/src/state" +) + +type RoomAPIHandler struct { + manager *state.Manager +} + +func NewRoomAPIHandler(manager *state.Manager) *RoomAPIHandler { + return &RoomAPIHandler{manager: manager} +} + +type createRoomRequest struct { + RoomName string `json:"roomName"` + CreatorUsername string `json:"creatorUsername"` + MaxPeople int `json:"maxPeople"` + Cards []string `json:"cards"` + AllowSpectators bool `json:"allowSpectators"` + AnonymousVoting bool `json:"anonymousVoting"` + AutoReset bool `json:"autoReset"` + RevealMode string `json:"revealMode"` + VotingTimeoutSec int `json:"votingTimeoutSec"` + Password string `json:"password"` +} + +type joinRoomRequest struct { + ParticipantID string `json:"participantId"` + Username string `json:"username"` + Role string `json:"role"` + Password string `json:"password"` + AdminToken string `json:"adminToken"` +} + +type voteRequest struct { + ParticipantID string `json:"participantId"` + Card string `json:"card"` +} + +type adminActionRequest struct { + ParticipantID string `json:"participantId"` +} + +func (h *RoomAPIHandler) CreateRoom(c *gin.Context) { + var req createRoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + result, err := h.manager.CreateRoom(state.CreateRoomInput{ + RoomName: req.RoomName, + CreatorUsername: req.CreatorUsername, + MaxPeople: req.MaxPeople, + Cards: req.Cards, + AllowSpectators: req.AllowSpectators, + AnonymousVoting: req.AnonymousVoting, + AutoReset: req.AutoReset, + RevealMode: req.RevealMode, + VotingTimeoutSec: req.VotingTimeoutSec, + Password: req.Password, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create room"}) + return + } + + c.JSON(http.StatusCreated, result) +} + +func (h *RoomAPIHandler) JoinRoom(c *gin.Context) { + var req joinRoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{ + ParticipantID: req.ParticipantID, + Username: req.Username, + Role: req.Role, + Password: req.Password, + AdminToken: req.AdminToken, + }) + if err != nil { + h.writeStateError(c, err) + return + } + + c.JSON(http.StatusOK, result) +} + +func (h *RoomAPIHandler) StreamEvents(c *gin.Context) { + roomID := c.Param("roomID") + participantID := c.Query("participantId") + if participantID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"}) + return + } + + stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID) + if err != nil { + h.writeStateError(c, err) + return + } + defer unsubscribe() + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"}) + return + } + + sendEvent := func(event string, payload []byte) error { + if _, err := fmt.Fprintf(c.Writer, "event: %s\n", event); err != nil { + return err + } + if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", payload); err != nil { + return err + } + flusher.Flush() + return nil + } + + if err := sendEvent("state", initial); err != nil { + return + } + + pingTicker := time.NewTicker(20 * time.Second) + defer pingTicker.Stop() + + for { + select { + case <-c.Request.Context().Done(): + return + case payload, ok := <-stream: + if !ok { + return + } + if err := sendEvent("state", payload); err != nil { + return + } + case <-pingTicker.C: + if _, err := c.Writer.Write([]byte(": ping\n\n")); err != nil { + return + } + flusher.Flush() + } + } +} + +func (h *RoomAPIHandler) CastVote(c *gin.Context) { + var req voteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.Card) + if err != nil { + h.writeStateError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *RoomAPIHandler) RevealVotes(c *gin.Context) { + h.handleAdminAction(c, h.manager.RevealVotes) +} + +func (h *RoomAPIHandler) ResetVotes(c *gin.Context) { + h.handleAdminAction(c, h.manager.ResetVotes) +} + +func (h *RoomAPIHandler) LeaveRoom(c *gin.Context) { + var req adminActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID); err != nil { + h.writeStateError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string) error) { + var req adminActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + if err := fn(c.Param("roomID"), req.ParticipantID); err != nil { + h.writeStateError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *RoomAPIHandler) writeStateError(c *gin.Context, err error) { + status := http.StatusInternalServerError + message := "internal error" + + switch { + case errors.Is(err, state.ErrRoomNotFound): + status = http.StatusNotFound + message = err.Error() + case errors.Is(err, state.ErrParticipantNotFound): + status = http.StatusNotFound + message = err.Error() + case errors.Is(err, state.ErrUnauthorized): + status = http.StatusForbidden + message = err.Error() + case errors.Is(err, state.ErrRoomFull): + status = http.StatusConflict + message = err.Error() + case errors.Is(err, state.ErrPasswordRequired): + status = http.StatusUnauthorized + message = err.Error() + case errors.Is(err, state.ErrSpectatorsBlocked), errors.Is(err, state.ErrInvalidCard), errors.Is(err, state.ErrInvalidRole): + status = http.StatusBadRequest + message = err.Error() + } + + c.JSON(status, gin.H{"error": message}) +} diff --git a/src/main.go b/src/main.go index 0fdf2e3..b5a35de 100644 --- a/src/main.go +++ b/src/main.go @@ -4,14 +4,22 @@ import ( "log" "scrum-solitare/src/config" - "scrum-solitare/src/controllers" + "scrum-solitare/src/handlers" "scrum-solitare/src/server" + "scrum-solitare/src/state" ) func main() { cfg := config.Load() - pageController := controllers.NewPageController() - router := server.NewRouter(pageController) + + manager, err := state.NewManager(cfg.DataPath) + if err != nil { + log.Fatalf("failed to initialize state manager: %v", err) + } + + pages := handlers.NewPageHandler() + rooms := handlers.NewRoomAPIHandler(manager) + router := server.NewRouter(pages, rooms) if err := router.Run(":" + cfg.Port); err != nil { log.Fatalf("server failed to start: %v", err) diff --git a/src/models/room_setup.go b/src/models/room_setup.go index 9918c9a..e04a0d5 100644 --- a/src/models/room_setup.go +++ b/src/models/room_setup.go @@ -7,7 +7,6 @@ type RoomSetupPageData struct { DefaultScale string DefaultRevealMode string DefaultVotingTime int - DefaultModerator string AllowSpectators bool AnonymousVoting bool AutoResetCards bool @@ -22,7 +21,6 @@ func DefaultRoomSetupPageData() RoomSetupPageData { DefaultScale: "fibonacci", DefaultRevealMode: "manual", DefaultVotingTime: 0, - DefaultModerator: "creator", AllowSpectators: true, AnonymousVoting: true, AutoResetCards: true, diff --git a/src/routes/routes.go b/src/routes/routes.go index 0cd938d..c57dfcc 100644 --- a/src/routes/routes.go +++ b/src/routes/routes.go @@ -5,13 +5,14 @@ import ( "github.com/gin-gonic/gin" - "scrum-solitare/src/controllers" + "scrum-solitare/src/handlers" "scrum-solitare/src/middleware" ) -func Register(r *gin.Engine, pageController *controllers.PageController) { +func Register(r *gin.Engine, pages *handlers.PageHandler, rooms *handlers.RoomAPIHandler) { registerStatic(r) - registerPages(r, pageController) + registerPages(r, pages) + registerAPI(r, rooms) } func registerStatic(r *gin.Engine) { @@ -20,6 +21,20 @@ func registerStatic(r *gin.Engine) { static.StaticFS("/", http.Dir("static")) } -func registerPages(r *gin.Engine, pageController *controllers.PageController) { - r.GET("/", pageController.ShowRoomSetup) +func registerPages(r *gin.Engine, pages *handlers.PageHandler) { + r.GET("/", pages.ShowConfigPage) + r.GET("/room/:roomID", pages.ShowRoomPage) +} + +func registerAPI(r *gin.Engine, rooms *handlers.RoomAPIHandler) { + api := r.Group("/api") + { + api.POST("/rooms", rooms.CreateRoom) + api.POST("/rooms/:roomID/join", rooms.JoinRoom) + api.GET("/rooms/:roomID/events", rooms.StreamEvents) + api.POST("/rooms/:roomID/vote", rooms.CastVote) + api.POST("/rooms/:roomID/reveal", rooms.RevealVotes) + api.POST("/rooms/:roomID/reset", rooms.ResetVotes) + api.POST("/rooms/:roomID/leave", rooms.LeaveRoom) + } } diff --git a/src/server/router.go b/src/server/router.go index 87a68ae..c26bf0b 100644 --- a/src/server/router.go +++ b/src/server/router.go @@ -4,17 +4,17 @@ import ( "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" - "scrum-solitare/src/controllers" + "scrum-solitare/src/handlers" "scrum-solitare/src/routes" ) -func NewRouter(pageController *controllers.PageController) *gin.Engine { +func NewRouter(pages *handlers.PageHandler, rooms *handlers.RoomAPIHandler) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) r.Use(gzip.Gzip(gzip.DefaultCompression)) r.LoadHTMLGlob("src/templates/*.html") - routes.Register(r, pageController) + routes.Register(r, pages, rooms) return r } diff --git a/src/state/manager.go b/src/state/manager.go new file mode 100644 index 0000000..30570eb --- /dev/null +++ b/src/state/manager.go @@ -0,0 +1,604 @@ +package state + +import ( + "encoding/json" + "slices" + "strings" + "sync" +) + +type Manager struct { + mu sync.RWMutex + rooms map[string]*Room + store *DiskStore +} + +func NewManager(dataPath string) (*Manager, error) { + store, err := NewDiskStore(dataPath) + if err != nil { + return nil, err + } + + manager := &Manager{ + rooms: make(map[string]*Room), + store: store, + } + + if loadErr := manager.loadFromDisk(); loadErr != nil { + return nil, loadErr + } + + return manager, nil +} + +func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) { + roomName := normalizeName(input.RoomName, 80) + creatorUsername := normalizeName(input.CreatorUsername, 32) + if roomName == "" { + roomName = "Scrum Poker Room" + } + if creatorUsername == "" { + creatorUsername = "host" + } + + maxPeople := input.MaxPeople + if maxPeople < 2 { + maxPeople = 2 + } + if maxPeople > 50 { + maxPeople = 50 + } + + revealMode := input.RevealMode + if revealMode != RevealModeManual && revealMode != RevealModeAutoAll { + revealMode = RevealModeManual + } + + cards := make([]string, 0, len(input.Cards)) + for _, rawCard := range input.Cards { + card := normalizeCard(rawCard) + if card == "" { + continue + } + cards = append(cards, card) + } + if len(cards) == 0 { + cards = []string{"0", "1", "2", "3", "5", "8", "13", "21", "?"} + } + + roomID := newUUIDv4() + adminToken := randomHex(24) + creatorID := newUUIDv4() + now := nowUTC() + + settings := RoomSettings{ + RoomName: roomName, + MaxPeople: maxPeople, + Cards: cards, + AllowSpectators: input.AllowSpectators, + AnonymousVoting: input.AnonymousVoting, + AutoReset: input.AutoReset, + RevealMode: revealMode, + VotingTimeoutSec: max(0, input.VotingTimeoutSec), + } + + password := strings.TrimSpace(input.Password) + if password != "" { + settings.PasswordSalt = randomHex(16) + settings.PasswordHash = hashPassword(password, settings.PasswordSalt) + } + + creator := &Participant{ + ID: creatorID, + Username: creatorUsername, + Role: RoleParticipant, + IsAdmin: true, + Connected: true, + HasVoted: false, + JoinedAt: now, + UpdatedAt: now, + } + + room := &Room{ + ID: roomID, + AdminToken: adminToken, + CreatedAt: now, + UpdatedAt: now, + Settings: settings, + Round: RoundState{ + Revealed: false, + }, + Participants: map[string]*Participant{ + creatorID: creator, + }, + subscribers: map[string]*subscriber{}, + } + + m.mu.Lock() + m.rooms[roomID] = room + m.mu.Unlock() + + room.mu.Lock() + if err := m.store.Save(room); err != nil { + room.mu.Unlock() + m.mu.Lock() + delete(m.rooms, roomID) + m.mu.Unlock() + return CreateRoomResult{}, err + } + room.mu.Unlock() + + result := CreateRoomResult{ + RoomID: roomID, + CreatorParticipantID: creatorID, + AdminToken: adminToken, + ParticipantLink: "/room/" + roomID, + AdminLink: "/room/" + roomID + "?adminToken=" + adminToken, + } + + return result, nil +} + +func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult, error) { + room, err := m.getRoom(roomID) + if err != nil { + return JoinRoomResult{}, err + } + + room.mu.Lock() + defer room.mu.Unlock() + + username := normalizeName(input.Username, 32) + if username == "" { + username = "anonymous" + } + + role := input.Role + if role == "" { + role = RoleParticipant + } + if role != RoleParticipant && role != RoleViewer { + return JoinRoomResult{}, ErrInvalidRole + } + + now := nowUTC() + isAdminByToken := input.AdminToken != "" && input.AdminToken == room.AdminToken + + if room.Settings.PasswordHash != "" && input.ParticipantID == "" { + if !passwordMatches(input.Password, room.Settings.PasswordSalt, room.Settings.PasswordHash) { + return JoinRoomResult{}, ErrPasswordRequired + } + } + + if input.ParticipantID != "" { + existing, ok := room.Participants[input.ParticipantID] + if !ok { + return JoinRoomResult{}, ErrParticipantNotFound + } + + existing.Username = username + existing.Connected = true + existing.UpdatedAt = now + if isAdminByToken { + existing.IsAdmin = true + } + + room.UpdatedAt = now + if err := m.store.Save(room); err != nil { + return JoinRoomResult{}, err + } + + go m.broadcastRoom(room.ID) + return JoinRoomResult{ + ParticipantID: existing.ID, + IsAdmin: existing.IsAdmin, + Role: existing.Role, + Username: existing.Username, + }, nil + } + + if role == RoleViewer && !room.Settings.AllowSpectators { + return JoinRoomResult{}, ErrSpectatorsBlocked + } + + if role == RoleParticipant { + count := 0 + for _, participant := range room.Participants { + if participant.Role == RoleParticipant { + count++ + } + } + if count >= room.Settings.MaxPeople { + return JoinRoomResult{}, ErrRoomFull + } + } + + participant := &Participant{ + ID: newUUIDv4(), + Username: username, + Role: role, + IsAdmin: isAdminByToken, + Connected: true, + HasVoted: false, + JoinedAt: now, + UpdatedAt: now, + } + + room.Participants[participant.ID] = participant + room.UpdatedAt = now + + if err := m.store.Save(room); err != nil { + return JoinRoomResult{}, err + } + + go m.broadcastRoom(room.ID) + return JoinRoomResult{ + ParticipantID: participant.ID, + IsAdmin: participant.IsAdmin, + Role: participant.Role, + Username: participant.Username, + }, nil +} + +func (m *Manager) LeaveRoom(roomID, participantID string) error { + room, err := m.getRoom(roomID) + if err != nil { + return err + } + + room.mu.Lock() + defer room.mu.Unlock() + + participant, ok := room.Participants[participantID] + if !ok { + return ErrParticipantNotFound + } + + participant.Connected = false + participant.UpdatedAt = nowUTC() + room.UpdatedAt = nowUTC() + + if err := m.store.Save(room); err != nil { + return err + } + + go m.broadcastRoom(room.ID) + return nil +} + +func (m *Manager) CastVote(roomID, participantID, card string) error { + room, err := m.getRoom(roomID) + if err != nil { + return err + } + + room.mu.Lock() + defer room.mu.Unlock() + + participant, ok := room.Participants[participantID] + if !ok { + return ErrParticipantNotFound + } + if participant.Role != RoleParticipant { + return ErrUnauthorized + } + + normalizedCard := normalizeCard(card) + if normalizedCard == "" || !slices.Contains(room.Settings.Cards, normalizedCard) { + return ErrInvalidCard + } + + if room.Round.Revealed { + if room.Settings.AutoReset { + m.resetVotesLocked(room) + } else { + return ErrUnauthorized + } + } + + participant.HasVoted = true + participant.VoteValue = normalizedCard + participant.UpdatedAt = nowUTC() + room.UpdatedAt = nowUTC() + + if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) { + room.Round.Revealed = true + } + + if err := m.store.Save(room); err != nil { + return err + } + + go m.broadcastRoom(room.ID) + return nil +} + +func (m *Manager) RevealVotes(roomID, participantID string) error { + room, err := m.getRoom(roomID) + if err != nil { + return err + } + + room.mu.Lock() + defer room.mu.Unlock() + + participant, ok := room.Participants[participantID] + if !ok { + return ErrParticipantNotFound + } + if !participant.IsAdmin { + return ErrUnauthorized + } + + room.Round.Revealed = true + room.UpdatedAt = nowUTC() + + if err := m.store.Save(room); err != nil { + return err + } + + go m.broadcastRoom(room.ID) + return nil +} + +func (m *Manager) ResetVotes(roomID, participantID string) error { + room, err := m.getRoom(roomID) + if err != nil { + return err + } + + room.mu.Lock() + defer room.mu.Unlock() + + participant, ok := room.Participants[participantID] + if !ok { + return ErrParticipantNotFound + } + if !participant.IsAdmin { + return ErrUnauthorized + } + + m.resetVotesLocked(room) + room.UpdatedAt = nowUTC() + + if err := m.store.Save(room); err != nil { + return err + } + + go m.broadcastRoom(room.ID) + return nil +} + +func (m *Manager) Subscribe(roomID, participantID string) (<-chan []byte, []byte, func(), error) { + room, err := m.getRoom(roomID) + if err != nil { + return nil, nil, nil, err + } + + room.mu.Lock() + participant, ok := room.Participants[participantID] + if !ok { + room.mu.Unlock() + return nil, nil, nil, ErrParticipantNotFound + } + + participant.Connected = true + participant.UpdatedAt = nowUTC() + subscriberID := randomHex(12) + ch := make(chan []byte, 16) + room.subscribers[subscriberID] = &subscriber{participantID: participantID, ch: ch} + room.UpdatedAt = nowUTC() + + if err := m.store.Save(room); err != nil { + room.mu.Unlock() + close(ch) + delete(room.subscribers, subscriberID) + return nil, nil, nil, err + } + room.mu.Unlock() + + initial, marshalErr := m.marshalRoomState(room, participantID) + if marshalErr != nil { + room.mu.Lock() + delete(room.subscribers, subscriberID) + close(ch) + room.mu.Unlock() + return nil, nil, nil, marshalErr + } + + unsubscribe := func() { + roomRef, getErr := m.getRoom(roomID) + if getErr != nil { + return + } + + roomRef.mu.Lock() + _, exists := roomRef.subscribers[subscriberID] + if !exists { + roomRef.mu.Unlock() + return + } + delete(roomRef.subscribers, subscriberID) + + if p, participantOK := roomRef.Participants[participantID]; participantOK { + p.Connected = false + p.UpdatedAt = nowUTC() + roomRef.UpdatedAt = nowUTC() + _ = m.store.Save(roomRef) + } + roomRef.mu.Unlock() + + go m.broadcastRoom(roomID) + } + + go m.broadcastRoom(roomID) + return ch, initial, unsubscribe, nil +} + +func (m *Manager) getRoom(roomID string) (*Room, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + room, ok := m.rooms[roomID] + if !ok { + return nil, ErrRoomNotFound + } + return room, nil +} + +func (m *Manager) loadFromDisk() error { + persistedRooms, err := m.store.LoadAll() + if err != nil { + return err + } + + for _, persisted := range persistedRooms { + room := &Room{ + ID: persisted.ID, + AdminToken: persisted.AdminToken, + CreatedAt: persisted.CreatedAt, + UpdatedAt: persisted.UpdatedAt, + Settings: persisted.Settings, + Round: persisted.Round, + Participants: make(map[string]*Participant, len(persisted.Participants)), + subscribers: map[string]*subscriber{}, + } + for _, participant := range persisted.Participants { + participant.Connected = false + room.Participants[participant.ID] = participant + } + + m.rooms[room.ID] = room + } + + return nil +} + +func (room *Room) toPersisted() persistedRoom { + participants := make([]*Participant, 0, len(room.Participants)) + for _, participant := range sortParticipants(room.Participants) { + clone := *participant + participants = append(participants, &clone) + } + + return persistedRoom{ + ID: room.ID, + AdminToken: room.AdminToken, + CreatedAt: room.CreatedAt, + UpdatedAt: room.UpdatedAt, + Settings: room.Settings, + Round: room.Round, + Participants: participants, + } +} + +func allActiveParticipantsVoted(room *Room) bool { + activeParticipants := 0 + for _, participant := range room.Participants { + if participant.Role != RoleParticipant || !participant.Connected { + continue + } + activeParticipants++ + if !participant.HasVoted { + return false + } + } + return activeParticipants > 0 +} + +func (m *Manager) resetVotesLocked(room *Room) { + room.Round.Revealed = false + for _, participant := range room.Participants { + if participant.Role != RoleParticipant { + continue + } + participant.HasVoted = false + participant.VoteValue = "" + participant.UpdatedAt = nowUTC() + } +} + +func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]byte, error) { + room.mu.RLock() + defer room.mu.RUnlock() + + viewer, ok := room.Participants[viewerParticipantID] + if !ok { + return nil, ErrParticipantNotFound + } + + participants := make([]PublicParticipant, 0, len(room.Participants)) + for _, participant := range sortParticipants(room.Participants) { + public := PublicParticipant{ + ID: participant.ID, + Username: participant.Username, + Role: participant.Role, + IsAdmin: participant.IsAdmin, + Connected: participant.Connected, + HasVoted: participant.HasVoted, + } + + if room.Round.Revealed { + public.VoteValue = participant.VoteValue + } else if participant.ID == viewerParticipantID { + public.VoteValue = participant.VoteValue + } + + participants = append(participants, public) + } + + state := PublicRoomState{ + RoomID: room.ID, + RoomName: room.Settings.RoomName, + Cards: append([]string(nil), room.Settings.Cards...), + Revealed: room.Round.Revealed, + RevealMode: room.Settings.RevealMode, + MaxPeople: room.Settings.MaxPeople, + AllowSpectators: room.Settings.AllowSpectators, + AnonymousVoting: room.Settings.AnonymousVoting, + AutoReset: room.Settings.AutoReset, + VotingTimeoutSec: room.Settings.VotingTimeoutSec, + Participants: participants, + SelfParticipantID: viewerParticipantID, + ViewerIsAdmin: viewer.IsAdmin, + Links: RoomLinks{ + ParticipantLink: "/room/" + room.ID, + }, + } + + if viewer.IsAdmin { + state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken + } + + return json.Marshal(state) +} + +func (m *Manager) broadcastRoom(roomID string) { + room, err := m.getRoom(roomID) + if err != nil { + return + } + + type target struct { + participantID string + ch chan []byte + } + + room.mu.RLock() + targets := make([]target, 0, len(room.subscribers)) + for _, subscriber := range room.subscribers { + targets = append(targets, target{participantID: subscriber.participantID, ch: subscriber.ch}) + } + room.mu.RUnlock() + + for _, t := range targets { + payload, marshalErr := m.marshalRoomState(room, t.participantID) + if marshalErr != nil { + continue + } + select { + case t.ch <- payload: + default: + } + } +} diff --git a/src/state/persistence.go b/src/state/persistence.go new file mode 100644 index 0000000..b4bc90f --- /dev/null +++ b/src/state/persistence.go @@ -0,0 +1,77 @@ +package state + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" +) + +type DiskStore struct { + dataPath string + mu sync.Mutex +} + +func NewDiskStore(dataPath string) (*DiskStore, error) { + if err := os.MkdirAll(dataPath, 0o755); err != nil { + return nil, err + } + + return &DiskStore{dataPath: dataPath}, nil +} + +func (ds *DiskStore) Save(room *Room) error { + ds.mu.Lock() + defer ds.mu.Unlock() + + persisted := room.toPersisted() + payload, err := json.MarshalIndent(persisted, "", " ") + if err != nil { + return err + } + + finalPath := filepath.Join(ds.dataPath, room.ID+".json") + tmpPath := finalPath + ".tmp" + if err := os.WriteFile(tmpPath, payload, 0o600); err != nil { + return err + } + + return os.Rename(tmpPath, finalPath) +} + +func (ds *DiskStore) LoadAll() ([]persistedRoom, error) { + ds.mu.Lock() + defer ds.mu.Unlock() + + entries, err := os.ReadDir(ds.dataPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + + rooms := make([]persistedRoom, 0) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + fullPath := filepath.Join(ds.dataPath, entry.Name()) + payload, readErr := os.ReadFile(fullPath) + if readErr != nil { + continue + } + + var room persistedRoom + if unmarshalErr := json.Unmarshal(payload, &room); unmarshalErr != nil { + continue + } + rooms = append(rooms, room) + } + + return rooms, nil +} diff --git a/src/state/types.go b/src/state/types.go new file mode 100644 index 0000000..75ce695 --- /dev/null +++ b/src/state/types.go @@ -0,0 +1,151 @@ +package state + +import ( + "errors" + "sync" + "time" +) + +const ( + RoleParticipant = "participant" + RoleViewer = "viewer" + + RevealModeManual = "manual" + RevealModeAutoAll = "all_voted" +) + +var ( + ErrRoomNotFound = errors.New("room not found") + ErrParticipantNotFound = errors.New("participant not found") + ErrUnauthorized = errors.New("unauthorized") + ErrRoomFull = errors.New("room is full") + ErrInvalidRole = errors.New("invalid role") + ErrSpectatorsBlocked = errors.New("spectators are not allowed") + ErrPasswordRequired = errors.New("password required or invalid") + ErrInvalidCard = errors.New("invalid card") +) + +type RoomSettings struct { + RoomName string `json:"roomName"` + MaxPeople int `json:"maxPeople"` + Cards []string `json:"cards"` + AllowSpectators bool `json:"allowSpectators"` + AnonymousVoting bool `json:"anonymousVoting"` + AutoReset bool `json:"autoReset"` + RevealMode string `json:"revealMode"` + VotingTimeoutSec int `json:"votingTimeoutSec"` + PasswordSalt string `json:"passwordSalt,omitempty"` + PasswordHash string `json:"passwordHash,omitempty"` +} + +type Participant struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + IsAdmin bool `json:"isAdmin"` + Connected bool `json:"connected"` + HasVoted bool `json:"hasVoted"` + VoteValue string `json:"voteValue,omitempty"` + JoinedAt time.Time `json:"joinedAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type RoundState struct { + Revealed bool `json:"revealed"` +} + +type persistedRoom struct { + ID string `json:"id"` + AdminToken string `json:"adminToken"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Settings RoomSettings `json:"settings"` + Round RoundState `json:"round"` + Participants []*Participant `json:"participants"` +} + +type subscriber struct { + participantID string + ch chan []byte +} + +type Room struct { + ID string + AdminToken string + CreatedAt time.Time + UpdatedAt time.Time + Settings RoomSettings + Round RoundState + Participants map[string]*Participant + + mu sync.RWMutex + subscribers map[string]*subscriber +} + +type CreateRoomInput struct { + RoomName string + CreatorUsername string + MaxPeople int + Cards []string + AllowSpectators bool + AnonymousVoting bool + AutoReset bool + RevealMode string + VotingTimeoutSec int + Password string +} + +type JoinRoomInput struct { + ParticipantID string + Username string + Role string + Password string + AdminToken string +} + +type CreateRoomResult struct { + RoomID string `json:"roomId"` + CreatorParticipantID string `json:"creatorParticipantId"` + AdminToken string `json:"adminToken"` + ParticipantLink string `json:"participantLink"` + AdminLink string `json:"adminLink"` +} + +type JoinRoomResult struct { + ParticipantID string `json:"participantId"` + IsAdmin bool `json:"isAdmin"` + Role string `json:"role"` + Username string `json:"username"` +} + +type PublicParticipant struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + IsAdmin bool `json:"isAdmin"` + Connected bool `json:"connected"` + HasVoted bool `json:"hasVoted"` + VoteValue string `json:"voteValue,omitempty"` +} + +type RoomLinks struct { + ParticipantLink string `json:"participantLink"` + AdminLink string `json:"adminLink,omitempty"` +} + +type PublicRoomState struct { + RoomID string `json:"roomId"` + RoomName string `json:"roomName"` + Cards []string `json:"cards"` + Revealed bool `json:"revealed"` + RevealMode string `json:"revealMode"` + MaxPeople int `json:"maxPeople"` + AllowSpectators bool `json:"allowSpectators"` + AnonymousVoting bool `json:"anonymousVoting"` + AutoReset bool `json:"autoReset"` + VotingTimeoutSec int `json:"votingTimeoutSec"` + Participants []PublicParticipant `json:"participants"` + SelfParticipantID string `json:"selfParticipantId"` + ViewerIsAdmin bool `json:"viewerIsAdmin"` + Links RoomLinks `json:"links"` +} diff --git a/src/state/utils.go b/src/state/utils.go new file mode 100644 index 0000000..abbf287 --- /dev/null +++ b/src/state/utils.go @@ -0,0 +1,83 @@ +package state + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "sort" + "strings" + "time" + "unicode" +) + +func randomHex(bytes int) string { + buf := make([]byte, bytes) + _, _ = rand.Read(buf) + return hex.EncodeToString(buf) +} + +func newUUIDv4() string { + buf := make([]byte, 16) + _, _ = rand.Read(buf) + buf[6] = (buf[6] & 0x0f) | 0x40 + buf[8] = (buf[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%x-%x-%x-%x-%x", buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]) +} + +func normalizeName(input string, max int) string { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "" + } + + builder := strings.Builder{} + for _, r := range trimmed { + if r == '\n' || r == '\r' || r == '\t' { + continue + } + if unicode.IsPrint(r) { + builder.WriteRune(r) + } + if builder.Len() >= max { + break + } + } + return strings.TrimSpace(builder.String()) +} + +func normalizeCard(input string) string { + return normalizeName(input, 8) +} + +func hashPassword(password, salt string) string { + h := sha256.Sum256([]byte(salt + ":" + password)) + return hex.EncodeToString(h[:]) +} + +func passwordMatches(password, salt, expectedHash string) bool { + computed := hashPassword(password, salt) + return subtle.ConstantTimeCompare([]byte(computed), []byte(expectedHash)) == 1 +} + +func nowUTC() time.Time { + return time.Now().UTC() +} + +func sortParticipants(participants map[string]*Participant) []*Participant { + list := make([]*Participant, 0, len(participants)) + for _, participant := range participants { + list = append(list, participant) + } + + sort.SliceStable(list, func(i, j int) bool { + if list[i].JoinedAt.Equal(list[j].JoinedAt) { + return list[i].Username < list[j].Username + } + return list[i].JoinedAt.Before(list[j].JoinedAt) + }) + + return list +} diff --git a/src/templates/body-config.html b/src/templates/body-config.html deleted file mode 100644 index 87c53b2..0000000 --- a/src/templates/body-config.html +++ /dev/null @@ -1,127 +0,0 @@ -{{ define "body" }} -
-
- CreateRoom.exe - -
- -
-

Configure your Scrum Poker room and share the invite link with your team.

- -
-
-
-
- - -
- -
-
- - -
- -
- -
- -
-
-
- -
-
- - -
- -
- - -
-
- -
-
- -
- - sec -
-
- -
- - -
-
- -
- Room options - - - -
-
- - -
- -
- {{ .DefaultStatus }} -
- -
- - -
-
-
-
-{{ end }} diff --git a/src/templates/footer.html b/src/templates/footer.html deleted file mode 100644 index 25c7ac7..0000000 --- a/src/templates/footer.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ define "footer" }} - - - - - -{{ end }} diff --git a/src/templates/header.html b/src/templates/header.html deleted file mode 100644 index 3cd3669..0000000 --- a/src/templates/header.html +++ /dev/null @@ -1,18 +0,0 @@ -{{ define "header" }} - - - - - - Retro Scrum Poker - Room Setup - - - - - - -
- -
-
-{{ end }} diff --git a/src/templates/index.html b/src/templates/index.html index cc1b7ad..5b3bff4 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -1,5 +1,144 @@ -{{ define "index.html" }} -{{ template "header" . }} -{{ template "body" . }} -{{ template "footer" . }} -{{ end }} + + + + + + Scrum Poker - Room Configuration + + + + + + +
+ +
+ +
+
+
+ CreateRoom.exe + +
+ +
+

Configure your Scrum Poker room and share the invite link with your team.

+ +
+
+
+
+ + +
+ +
+
+ + +
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ +
+ + sec +
+
+ +
+ + +
+
+ +
+ Room options + + + +
+
+ + +
+ +
{{ .DefaultStatus }}
+ +
+ + +
+
+
+
+
+ + + + diff --git a/src/templates/room.html b/src/templates/room.html new file mode 100644 index 0000000..2bebb6b --- /dev/null +++ b/src/templates/room.html @@ -0,0 +1,102 @@ + + + + + + Scrum Poker Room + + + + + + +
+ +
+ +
+
+
+
+ Room + +
+
+
+ Reveal mode: manual + Cards hidden +
+
+
+
+ + + +
+
+ Controls +
+
+ + +

Connecting...

+
+
+
+ + +
+ + + + diff --git a/static/css/styles.css b/static/css/styles.css index ba3a893..4995dec 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -27,7 +27,7 @@ --title-text: #00ff00; --input-bg: #111111; --status-bg: #1b1b1b; - --board-bg: #0a2c14; + --board-bg: #0b2f16; --card-bg: #171717; --card-text: #00ff66; } @@ -107,41 +107,12 @@ body { padding: 12px; } -.config-window { - width: 100%; - max-width: 980px; -} - -.intro-copy { - font-size: 1.3rem; - margin-bottom: 12px; -} - .room-form { display: flex; flex-direction: column; gap: 10px; } -.config-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 320px; - gap: 12px; - align-items: start; -} - -.config-panel { - display: flex; - flex-direction: column; - gap: 10px; -} - -.field-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - .field-group { display: flex; flex-direction: column; @@ -155,6 +126,7 @@ legend { input[type="text"], input[type="number"], +input[type="password"], select { background: var(--input-bg); color: var(--window-text); @@ -166,8 +138,7 @@ select { outline: none; } -input[type="text"]:focus, -input[type="number"]:focus, +input:focus, select:focus { box-shadow: inset 0 0 0 1px var(--title-bg); } @@ -211,6 +182,76 @@ input[type="number"]::-webkit-inner-spin-button { text-align: right; } +.btn { + background: var(--window-bg); + color: var(--window-text); + border: 2px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); + box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark); + padding: 4px 12px; + font-size: 1.2rem; + cursor: pointer; + margin-left: 6px; +} + +.btn:active { + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + box-shadow: inset 1px 1px var(--border-mid-dark), inset -1px -1px var(--border-mid-light); + padding: 5px 11px 3px 13px; +} + +.btn-primary { + font-weight: bold; +} + +.actions-row { + text-align: right; + margin-top: 4px; +} + +.status-line { + background: var(--status-bg); + border: 2px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + padding: 5px 8px; + font-size: 1.1rem; + min-height: 30px; +} + +.hidden { + display: none !important; +} + +/* Config page */ +.config-window { + width: 100%; + max-width: 980px; +} + +.intro-copy { + font-size: 1.3rem; + margin-bottom: 12px; +} + +.config-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 12px; + align-items: start; +} + +.config-panel { + display: flex; + flex-direction: column; + gap: 10px; +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + .options-box { padding: 8px; } @@ -284,8 +325,12 @@ input[type="number"]::-webkit-inner-spin-button { font-weight: bold; box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5); user-select: none; - transition: transform 180ms ease, opacity 180ms ease; - cursor: default; + transition: transform 180ms ease; + cursor: grab; +} + +.preview-card.dragging { + opacity: 0.5; } .preview-card-remove { @@ -315,8 +360,22 @@ input[type="number"]::-webkit-inner-spin-button { } .preview-card.is-removing { - opacity: 0; - transform: scale(0.55) rotate(-8deg); + animation: card-pop-out 190ms ease forwards; +} + +@keyframes card-pop-out { + from { + opacity: 1; + transform: scale(1) rotate(0deg); + } + to { + opacity: 0; + transform: scale(0.58) rotate(-10deg); + } +} + +.hint-text { + font-size: 1rem; } .card-editor { @@ -335,73 +394,145 @@ input[type="number"]::-webkit-inner-spin-button { gap: 6px; } -.status-line { - background: var(--status-bg); - border: 2px solid; - border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); - padding: 5px 8px; - font-size: 1.1rem; - min-height: 30px; +/* Room page */ +.room-desktop { + align-items: stretch; + justify-content: center; + padding-top: 60px; } -.actions-row { - text-align: right; - margin-top: 4px; +.room-grid { + width: min(1180px, 100%); + display: grid; + gap: 12px; + grid-template-columns: 2fr 1fr; + grid-template-rows: auto auto; + grid-template-areas: + "main participants" + "controls participants"; } -.btn { - background: var(--window-bg); - color: var(--window-text); - border: 2px solid; - border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); - box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark); - padding: 4px 12px; - font-size: 1.2rem; - cursor: pointer; - margin-left: 6px; +.room-main-window { + grid-area: main; } -.btn:active { - border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); - box-shadow: inset 1px 1px var(--border-mid-dark), inset -1px -1px var(--border-mid-light); - padding: 5px 11px 3px 13px; +.participants-window { + grid-area: participants; } -.btn-primary { - font-weight: bold; +.control-window { + grid-area: controls; } -.taskbar { - height: 30px; - background: var(--window-bg); - border-top: 2px solid var(--border-light); - box-shadow: inset 0 1px var(--border-mid-light); +.room-meta { display: flex; - align-items: center; - gap: 8px; - padding: 3px 6px; + justify-content: space-between; + margin-bottom: 10px; + font-size: 1.15rem; } -.taskbar-start, -.taskbar-status { +.voting-board { + background: var(--board-bg); border: 2px solid; - border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); - box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark); - padding: 0 8px; - font-size: 1.1rem; - height: 20px; - display: inline-flex; - align-items: center; + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + min-height: 260px; + padding: 12px; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-content: flex-start; } -.taskbar-status { - min-width: 180px; +.vote-card { + width: 72px; + height: 100px; + border-radius: 6px; + border: 2px solid #000; + background: var(--card-bg); + color: var(--card-text); + font-size: 2rem; + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.45); + cursor: pointer; +} + +.vote-card:hover { + transform: translateY(-3px); +} + +.vote-card.is-selected { + outline: 3px solid #ffd200; +} + +.participants-content { + max-height: 520px; + overflow-y: auto; +} + +.participant-list { + list-style: none; + background: var(--input-bg); + border: 2px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + padding: 5px; +} + +.participant-item { + display: flex; + justify-content: space-between; + padding: 5px; + border-bottom: 1px dashed var(--border-mid-dark); + font-size: 1.15rem; +} + +.participant-item:last-child { + border-bottom: 0; +} + +.control-content { + display: flex; + flex-direction: column; + gap: 10px; +} + +.links-block { + display: grid; + gap: 4px; +} + +.links-block input { + font-size: 1rem; +} + +.admin-controls { + display: flex; + justify-content: flex-end; +} + +.join-window { + position: fixed; + z-index: 30; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(420px, 92vw); +} + +#join-error { + margin-top: 8px; } @media (max-width: 960px) { - .config-layout { + .config-layout, + .room-grid { grid-template-columns: 1fr; } + + .room-grid { + grid-template-areas: + "main" + "participants" + "controls"; + } } @media (max-width: 720px) { diff --git a/static/js/app.js b/static/js/app.js deleted file mode 100644 index e98105d..0000000 --- a/static/js/app.js +++ /dev/null @@ -1,184 +0,0 @@ -const themeToggleBtn = document.getElementById('theme-toggle'); -const roomConfigForm = document.getElementById('room-config-form'); -const statusLine = document.getElementById('config-status'); -const scaleSelect = document.getElementById('estimation-scale'); -const maxPeopleInput = document.getElementById('max-people'); -const previewScale = document.getElementById('preview-scale'); -const previewMaxPeople = document.getElementById('preview-max-people'); -const previewCards = document.getElementById('preview-cards'); -const customCardInput = document.getElementById('custom-card'); -const addCardButton = document.getElementById('add-card'); - -const SCALE_PRESETS = { - fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'], - tshirt: ['XS', 'S', 'M', 'L', 'XL', '?'], - 'powers-of-two': ['1', '2', '4', '8', '16', '32', '?'], -}; - -let isDarkMode = false; -let nextCardID = 1; -let currentCards = []; - -themeToggleBtn.addEventListener('click', () => { - isDarkMode = !isDarkMode; - if (isDarkMode) { - document.documentElement.setAttribute('data-theme', 'dark'); - themeToggleBtn.textContent = 'Light Mode'; - return; - } - - document.documentElement.removeAttribute('data-theme'); - themeToggleBtn.textContent = 'Dark Mode'; -}); - -function createCard(value) { - return { id: nextCardID++, value: value.toString() }; -} - -function getCardsForScale(scale) { - return (SCALE_PRESETS[scale] || SCALE_PRESETS.fibonacci).map(createCard); -} - -function captureCardPositions() { - const positions = new Map(); - previewCards.querySelectorAll('.preview-card').forEach((el) => { - positions.set(el.dataset.cardId, el.getBoundingClientRect()); - }); - return positions; -} - -function animateCardReflow(previousPositions) { - previewCards.querySelectorAll('.preview-card').forEach((el) => { - const oldRect = previousPositions.get(el.dataset.cardId); - if (!oldRect) { - el.style.opacity = '0'; - el.style.transform = 'scale(0.85)'; - requestAnimationFrame(() => { - el.style.opacity = '1'; - el.style.transform = 'translate(0, 0) scale(1)'; - }); - return; - } - - const newRect = el.getBoundingClientRect(); - const deltaX = oldRect.left - newRect.left; - const deltaY = oldRect.top - newRect.top; - - if (deltaX === 0 && deltaY === 0) { - return; - } - - el.style.transform = `translate(${deltaX}px, ${deltaY}px)`; - requestAnimationFrame(() => { - el.style.transform = 'translate(0, 0)'; - }); - }); -} - -function renderCards(previousPositions = new Map()) { - previewCards.innerHTML = ''; - - currentCards.forEach((card) => { - const cardEl = document.createElement('div'); - cardEl.className = 'preview-card'; - cardEl.dataset.cardId = String(card.id); - cardEl.textContent = card.value; - - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'preview-card-remove'; - removeBtn.textContent = 'X'; - removeBtn.setAttribute('aria-label', `Remove card ${card.value}`); - removeBtn.addEventListener('click', (event) => { - event.stopPropagation(); - removeCard(card.id); - }); - - cardEl.appendChild(removeBtn); - previewCards.appendChild(cardEl); - }); - - animateCardReflow(previousPositions); -} - -function removeCard(cardID) { - const cardEl = previewCards.querySelector(`[data-card-id="${cardID}"]`); - if (!cardEl) { - return; - } - - cardEl.classList.add('is-removing'); - - window.setTimeout(() => { - const previousPositions = captureCardPositions(); - currentCards = currentCards.filter((card) => card.id !== cardID); - renderCards(previousPositions); - }, 160); -} - -function resetCardsForCurrentScale() { - const previousPositions = captureCardPositions(); - currentCards = getCardsForScale(scaleSelect.value); - renderCards(previousPositions); -} - -function updatePreviewMeta() { - previewScale.textContent = `Scale: ${scaleSelect.value}`; - previewMaxPeople.textContent = `Max: ${maxPeopleInput.value || 0}`; -} - -addCardButton.addEventListener('click', () => { - const value = customCardInput.value.trim(); - if (!value) { - return; - } - - const previousPositions = captureCardPositions(); - currentCards.push(createCard(value.slice(0, 8))); - renderCards(previousPositions); - customCardInput.value = ''; - customCardInput.focus(); -}); - -customCardInput.addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - event.preventDefault(); - addCardButton.click(); - } -}); - -scaleSelect.addEventListener('change', () => { - resetCardsForCurrentScale(); - updatePreviewMeta(); - statusLine.textContent = 'Card deck reset to selected estimation scale.'; -}); - -maxPeopleInput.addEventListener('input', () => { - updatePreviewMeta(); -}); - -roomConfigForm.addEventListener('submit', (event) => { - event.preventDefault(); - - const formData = new FormData(roomConfigForm); - const roomName = (formData.get('roomName') || '').toString().trim(); - const username = (formData.get('username') || '').toString().trim(); - - if (!roomName || !username) { - statusLine.textContent = 'Please fill in both room name and username.'; - return; - } - - statusLine.textContent = `Room "${roomName}" prepared by ${username} with ${currentCards.length} cards.`; -}); - -roomConfigForm.addEventListener('reset', () => { - window.setTimeout(() => { - updatePreviewMeta(); - resetCardsForCurrentScale(); - statusLine.textContent = 'Room settings reset to defaults.'; - }, 0); -}); - -updatePreviewMeta(); -resetCardsForCurrentScale(); diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 0000000..5157766 --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,260 @@ +const USERNAME_KEY = 'scrumPoker.username'; + +const SCALE_PRESETS = { + fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'], + tshirt: ['XS', 'S', 'M', 'L', 'XL', '?'], + 'powers-of-two': ['1', '2', '4', '8', '16', '32', '?'], +}; + +const themeToggleBtn = document.getElementById('theme-toggle'); +const roomConfigForm = document.getElementById('room-config-form'); +const statusLine = document.getElementById('config-status'); +const scaleSelect = document.getElementById('estimation-scale'); +const maxPeopleInput = document.getElementById('max-people'); +const previewScale = document.getElementById('preview-scale'); +const previewMaxPeople = document.getElementById('preview-max-people'); +const previewCards = document.getElementById('preview-cards'); +const customCardInput = document.getElementById('custom-card'); +const addCardButton = document.getElementById('add-card'); +const usernameInput = document.getElementById('username'); + +let isDarkMode = false; +let nextCardID = 1; +let currentCards = []; +let draggingCardID = ''; + +const savedUsername = localStorage.getItem(USERNAME_KEY); +if (savedUsername && !usernameInput.value) { + usernameInput.value = savedUsername; +} + +themeToggleBtn.addEventListener('click', () => { + isDarkMode = !isDarkMode; + if (isDarkMode) { + document.documentElement.setAttribute('data-theme', 'dark'); + themeToggleBtn.textContent = 'Light Mode'; + return; + } + + document.documentElement.removeAttribute('data-theme'); + themeToggleBtn.textContent = 'Dark Mode'; +}); + +function createCard(value) { + return { id: String(nextCardID++), value: value.toString() }; +} + +function getCardsForScale(scale) { + return (SCALE_PRESETS[scale] || SCALE_PRESETS.fibonacci).map(createCard); +} + +function captureCardPositions() { + const positions = new Map(); + previewCards.querySelectorAll('.preview-card').forEach((el) => { + positions.set(el.dataset.cardId, el.getBoundingClientRect()); + }); + return positions; +} + +function animateReflow(previousPositions) { + previewCards.querySelectorAll('.preview-card').forEach((el) => { + const previousRect = previousPositions.get(el.dataset.cardId); + if (!previousRect) { + return; + } + + const nextRect = el.getBoundingClientRect(); + const deltaX = previousRect.left - nextRect.left; + const deltaY = previousRect.top - nextRect.top; + if (!deltaX && !deltaY) { + return; + } + + el.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + requestAnimationFrame(() => { + el.style.transform = 'translate(0, 0)'; + }); + }); +} + +function removeCard(cardID) { + const cardEl = previewCards.querySelector(`[data-card-id="${cardID}"]`); + if (!cardEl) { + return; + } + + cardEl.classList.add('is-removing'); + cardEl.addEventListener('animationend', () => { + const previousPositions = captureCardPositions(); + currentCards = currentCards.filter((card) => card.id !== cardID); + renderCards(previousPositions); + }, { once: true }); +} + +function reorderCard(fromID, toID) { + if (fromID === toID) { + return; + } + + const fromIndex = currentCards.findIndex((card) => card.id === fromID); + const toIndex = currentCards.findIndex((card) => card.id === toID); + if (fromIndex < 0 || toIndex < 0) { + return; + } + + const previousPositions = captureCardPositions(); + const [moved] = currentCards.splice(fromIndex, 1); + currentCards.splice(toIndex, 0, moved); + renderCards(previousPositions); +} + +function buildCardElement(card) { + const cardEl = document.createElement('div'); + cardEl.className = 'preview-card'; + cardEl.dataset.cardId = card.id; + cardEl.textContent = card.value; + cardEl.draggable = true; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'preview-card-remove'; + removeBtn.textContent = 'X'; + removeBtn.setAttribute('aria-label', `Remove ${card.value}`); + removeBtn.addEventListener('click', (event) => { + event.stopPropagation(); + removeCard(card.id); + }); + + cardEl.addEventListener('dragstart', () => { + draggingCardID = card.id; + cardEl.classList.add('dragging'); + }); + + cardEl.addEventListener('dragend', () => { + draggingCardID = ''; + cardEl.classList.remove('dragging'); + }); + + cardEl.addEventListener('dragover', (event) => { + event.preventDefault(); + }); + + cardEl.addEventListener('drop', (event) => { + event.preventDefault(); + if (!draggingCardID) { + return; + } + reorderCard(draggingCardID, card.id); + }); + + cardEl.appendChild(removeBtn); + return cardEl; +} + +function renderCards(previousPositions = new Map()) { + previewCards.innerHTML = ''; + currentCards.forEach((card) => { + previewCards.appendChild(buildCardElement(card)); + }); + + animateReflow(previousPositions); +} + +function resetCardsForScale() { + const previousPositions = captureCardPositions(); + currentCards = getCardsForScale(scaleSelect.value); + renderCards(previousPositions); +} + +function updatePreviewMeta() { + previewScale.textContent = `Scale: ${scaleSelect.value}`; + previewMaxPeople.textContent = `Max: ${maxPeopleInput.value || 0}`; +} + +addCardButton.addEventListener('click', () => { + const value = customCardInput.value.trim(); + if (!value) { + return; + } + + const previousPositions = captureCardPositions(); + currentCards.push(createCard(value.slice(0, 8))); + renderCards(previousPositions); + customCardInput.value = ''; + customCardInput.focus(); +}); + +customCardInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addCardButton.click(); + } +}); + +scaleSelect.addEventListener('change', () => { + resetCardsForScale(); + updatePreviewMeta(); + statusLine.textContent = 'Card deck reset to selected estimation scale.'; +}); + +maxPeopleInput.addEventListener('input', updatePreviewMeta); + +roomConfigForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + const formData = new FormData(roomConfigForm); + const username = (formData.get('username') || '').toString().trim(); + const roomName = (formData.get('roomName') || '').toString().trim(); + + if (!username || !roomName) { + statusLine.textContent = 'Room name and username are required.'; + return; + } + + localStorage.setItem(USERNAME_KEY, username); + + const payload = { + roomName, + creatorUsername: username, + maxPeople: Number(formData.get('maxPeople') || 50), + cards: currentCards.map((card) => card.value), + allowSpectators: Boolean(formData.get('allowSpectators')), + anonymousVoting: Boolean(formData.get('anonymousVoting')), + autoReset: Boolean(formData.get('autoReset')), + revealMode: (formData.get('revealMode') || 'manual').toString(), + votingTimeoutSec: Number(formData.get('votingTimeoutSec') || 0), + password: (formData.get('password') || '').toString(), + }; + + statusLine.textContent = 'Creating room...'; + + try { + const response = await fetch('/api/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + if (!response.ok) { + statusLine.textContent = data.error || 'Failed to create room.'; + return; + } + + const target = `/room/${encodeURIComponent(data.roomId)}?participantId=${encodeURIComponent(data.creatorParticipantId)}&adminToken=${encodeURIComponent(data.adminToken)}`; + window.location.assign(target); + } catch (err) { + statusLine.textContent = 'Network error while creating room.'; + } +}); + +roomConfigForm.addEventListener('reset', () => { + window.setTimeout(() => { + updatePreviewMeta(); + resetCardsForScale(); + statusLine.textContent = 'Room settings reset to defaults.'; + }, 0); +}); + +updatePreviewMeta(); +resetCardsForScale(); diff --git a/static/js/room.js b/static/js/room.js new file mode 100644 index 0000000..4ce1faf --- /dev/null +++ b/static/js/room.js @@ -0,0 +1,306 @@ +const USERNAME_KEY = 'scrumPoker.username'; + +const roomID = document.body.dataset.roomId; +const params = new URLSearchParams(window.location.search); + +const themeToggleBtn = document.getElementById('theme-toggle'); +const roomTitle = document.getElementById('room-title'); +const revealModeLabel = document.getElementById('reveal-mode-label'); +const roundStateLabel = document.getElementById('round-state-label'); +const votingBoard = document.getElementById('voting-board'); +const participantList = document.getElementById('participant-list'); +const adminControls = document.getElementById('admin-controls'); +const revealBtn = document.getElementById('reveal-btn'); +const resetBtn = document.getElementById('reset-btn'); +const participantLinkInput = document.getElementById('participant-link'); +const adminLinkInput = document.getElementById('admin-link'); +const roomStatus = document.getElementById('room-status'); + +const joinPanel = document.getElementById('join-panel'); +const joinForm = document.getElementById('join-form'); +const joinUsernameInput = document.getElementById('join-username'); +const joinRoleInput = document.getElementById('join-role'); +const joinPasswordInput = document.getElementById('join-password'); +const joinAdminTokenInput = document.getElementById('join-admin-token'); +const joinError = document.getElementById('join-error'); + +let isDarkMode = false; +let participantID = params.get('participantId') || ''; +let adminToken = params.get('adminToken') || ''; +let eventSource = null; +let latestState = null; + +themeToggleBtn.addEventListener('click', () => { + isDarkMode = !isDarkMode; + if (isDarkMode) { + document.documentElement.setAttribute('data-theme', 'dark'); + themeToggleBtn.textContent = 'Light Mode'; + return; + } + + document.documentElement.removeAttribute('data-theme'); + themeToggleBtn.textContent = 'Dark Mode'; +}); + +const savedUsername = localStorage.getItem(USERNAME_KEY) || ''; +joinUsernameInput.value = savedUsername; +joinAdminTokenInput.value = adminToken; + +function setJoinError(message) { + if (!message) { + joinError.classList.add('hidden'); + joinError.textContent = ''; + return; + } + + joinError.classList.remove('hidden'); + joinError.textContent = message; +} + +function updateURL() { + const next = new URL(window.location.href); + if (participantID) { + next.searchParams.set('participantId', participantID); + } else { + next.searchParams.delete('participantId'); + } + if (adminToken) { + next.searchParams.set('adminToken', adminToken); + } else { + next.searchParams.delete('adminToken'); + } + window.history.replaceState({}, '', next.toString()); +} + +async function joinRoom({ username, role, password, participantIdOverride }) { + const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + participantId: participantIdOverride || participantID, + username, + role, + password, + adminToken, + }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Unable to join room.'); + } + + participantID = data.participantId; + if (data.isAdmin && !adminToken) { + adminToken = joinAdminTokenInput.value.trim(); + } + + localStorage.setItem(USERNAME_KEY, data.username); + updateURL(); + joinPanel.classList.add('hidden'); + setJoinError(''); +} + +function renderParticipants(participants, isRevealed) { + participantList.innerHTML = ''; + participants.forEach((participant) => { + const item = document.createElement('li'); + item.className = 'participant-item'; + + const name = document.createElement('span'); + let label = participant.username; + if (participant.id === participantID) { + label += ' (You)'; + } + if (participant.isAdmin) { + label += ' [Admin]'; + } + name.textContent = label; + + const status = document.createElement('span'); + if (participant.role === 'viewer') { + status.textContent = 'Viewer'; + } else if (!participant.hasVoted) { + status.textContent = 'Voting...'; + } else if (isRevealed) { + status.textContent = participant.voteValue || '-'; + } else { + status.textContent = participant.voteValue ? `Voted (${participant.voteValue})` : 'Voted'; + } + + item.appendChild(name); + item.appendChild(status); + participantList.appendChild(item); + }); +} + +function renderCards(cards, participants, isRevealed) { + const self = participants.find((participant) => participant.id === participantID); + const canVote = self && self.role === 'participant'; + const selfVote = self ? self.voteValue : ''; + + votingBoard.innerHTML = ''; + cards.forEach((value) => { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'vote-card'; + card.textContent = value; + + if (selfVote === value && !isRevealed) { + card.classList.add('is-selected'); + } + + card.disabled = !canVote; + card.addEventListener('click', () => castVote(value)); + votingBoard.appendChild(card); + }); +} + +function renderState(state) { + latestState = state; + roomTitle.textContent = `${state.roomName} (${state.roomId})`; + revealModeLabel.textContent = `Reveal mode: ${state.revealMode}`; + roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden'; + + renderParticipants(state.participants, state.revealed); + renderCards(state.cards, state.participants, state.revealed); + + participantLinkInput.value = `${window.location.origin}${state.links.participantLink}`; + adminLinkInput.value = state.links.adminLink ? `${window.location.origin}${state.links.adminLink}` : ''; + + if (state.viewerIsAdmin) { + adminControls.classList.remove('hidden'); + } else { + adminControls.classList.add('hidden'); + } + + const votedCount = state.participants.filter((p) => p.role === 'participant' && p.hasVoted).length; + const totalParticipants = state.participants.filter((p) => p.role === 'participant').length; + roomStatus.textContent = `Votes: ${votedCount}/${totalParticipants}`; +} + +function connectSSE() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource(`/api/rooms/${encodeURIComponent(roomID)}/events?participantId=${encodeURIComponent(participantID)}`); + eventSource.addEventListener('state', (event) => { + try { + const payload = JSON.parse(event.data); + renderState(payload); + } catch (_err) { + roomStatus.textContent = 'Failed to parse room update.'; + } + }); + + eventSource.onerror = () => { + roomStatus.textContent = 'Connection interrupted. Retrying...'; + }; +} + +async function castVote(card) { + if (!participantID) { + return; + } + + try { + const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ participantId: participantID, card }), + }); + + if (!response.ok) { + const data = await response.json(); + roomStatus.textContent = data.error || 'Vote rejected.'; + } + } catch (_err) { + roomStatus.textContent = 'Network error while casting vote.'; + } +} + +async function adminAction(action) { + if (!participantID) { + return; + } + + try { + const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ participantId: participantID }), + }); + + if (!response.ok) { + const data = await response.json(); + roomStatus.textContent = data.error || `Unable to ${action}.`; + } + } catch (_err) { + roomStatus.textContent = 'Network error while sending admin action.'; + } +} + +revealBtn.addEventListener('click', () => adminAction('reveal')); +resetBtn.addEventListener('click', () => adminAction('reset')); + +joinForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + const username = joinUsernameInput.value.trim(); + if (!username) { + setJoinError('Username is required.'); + return; + } + + adminToken = joinAdminTokenInput.value.trim(); + + try { + await joinRoom({ + username, + role: joinRoleInput.value, + password: joinPasswordInput.value, + }); + connectSSE(); + } catch (err) { + setJoinError(err.message); + } +}); + +window.addEventListener('pagehide', () => { + if (!participantID) { + return; + } + + const payload = JSON.stringify({ participantId: participantID }); + navigator.sendBeacon(`/api/rooms/${encodeURIComponent(roomID)}/leave`, new Blob([payload], { type: 'application/json' })); +}); + +async function bootstrap() { + if (!participantID) { + joinPanel.classList.remove('hidden'); + return; + } + + if (!savedUsername) { + joinPanel.classList.remove('hidden'); + return; + } + + try { + await joinRoom({ + username: savedUsername, + role: 'participant', + password: '', + participantIdOverride: participantID, + }); + connectSSE(); + } catch (_err) { + participantID = ''; + updateURL(); + joinPanel.classList.remove('hidden'); + setJoinError('Please join this room to continue.'); + } +} + +bootstrap();