Update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,3 +24,7 @@ Thumbs.db
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data
|
||||||
@@ -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/src/templates /app/src/templates
|
||||||
COPY --from=build /app/static /app/static
|
COPY --from=build /app/static /app/static
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chown -R app:app /app
|
||||||
|
|
||||||
EXPOSE 8002
|
EXPOSE 8002
|
||||||
ENV PORT=8002
|
ENV PORT=8002
|
||||||
|
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1,29 +1,40 @@
|
|||||||
# Scrum Solitare
|
# 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`
|
- Go backend with layered architecture (`handlers`, `state`, `routes`, `config`)
|
||||||
- Gzip compression enabled
|
- Memory-first room state with disk synchronization to JSON files
|
||||||
- Cache headers for static `css`, `js`, and image assets
|
- Real-time updates via Server-Sent Events (SSE)
|
||||||
- Template rendering from `src/templates`
|
- Strict state sanitization: unrevealed votes from other users are never broadcast
|
||||||
- Static file hosting from `static/`
|
- Backend authorization for admin-only actions (`reveal`, `reset`)
|
||||||
- `/` currently serves a room configuration page (UI only)
|
- 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
|
## Project Layout
|
||||||
|
|
||||||
- `src/main.go`: Application bootstrap
|
- `src/main.go`: app bootstrap
|
||||||
- `src/config/`: Environment and runtime configuration
|
- `src/config/`: environment configuration
|
||||||
- `src/server/`: Gin engine construction and middleware wiring
|
- `src/server/`: Gin engine setup
|
||||||
- `src/routes/`: Route registration
|
- `src/routes/`: page/api route registration
|
||||||
- `src/controllers/`: HTTP handlers/controllers
|
- `src/handlers/`: HTTP handlers (pages + API + SSE)
|
||||||
- `src/middleware/`: Custom Gin middleware
|
- `src/state/`: in-memory room manager, sanitization, persistence
|
||||||
- `src/models/`: Page/view data models
|
- `src/middleware/`: static cache headers middleware
|
||||||
- `src/templates/`: HTML templates (`header`, `body`, `footer`, and `index` composition)
|
- `src/models/`: template page data models
|
||||||
- `static/css/`: Stylesheets
|
- `src/templates/`: HTML templates (`index.html`, `room.html`)
|
||||||
- `static/js/`: Frontend scripts
|
- `static/css/`: styles
|
||||||
- `static/img/`: Image assets
|
- `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
|
## Run Locally
|
||||||
|
|
||||||
@@ -34,22 +45,27 @@ go run ./src
|
|||||||
|
|
||||||
Open `http://localhost:8002`.
|
Open `http://localhost:8002`.
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
- `PORT`: Optional server port override (default is `8002`)
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build image:
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t scrum-solitare .
|
docker build -t scrum-solitare .
|
||||||
```
|
```
|
||||||
|
|
||||||
Run container:
|
Run:
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "os"
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
|
DataPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -12,5 +13,13 @@ func Load() Config {
|
|||||||
port = "8002"
|
port = "8002"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Config{Port: port}
|
dataPath := os.Getenv("DATA_PATH")
|
||||||
|
if dataPath == "" {
|
||||||
|
dataPath = "./data"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{
|
||||||
|
Port: port,
|
||||||
|
DataPath: dataPath,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
26
src/handlers/pages.go
Normal file
26
src/handlers/pages.go
Normal file
@@ -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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
243
src/handlers/room_api.go
Normal file
243
src/handlers/room_api.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
14
src/main.go
14
src/main.go
@@ -4,14 +4,22 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"scrum-solitare/src/config"
|
"scrum-solitare/src/config"
|
||||||
"scrum-solitare/src/controllers"
|
"scrum-solitare/src/handlers"
|
||||||
"scrum-solitare/src/server"
|
"scrum-solitare/src/server"
|
||||||
|
"scrum-solitare/src/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
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 {
|
if err := router.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatalf("server failed to start: %v", err)
|
log.Fatalf("server failed to start: %v", err)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ type RoomSetupPageData struct {
|
|||||||
DefaultScale string
|
DefaultScale string
|
||||||
DefaultRevealMode string
|
DefaultRevealMode string
|
||||||
DefaultVotingTime int
|
DefaultVotingTime int
|
||||||
DefaultModerator string
|
|
||||||
AllowSpectators bool
|
AllowSpectators bool
|
||||||
AnonymousVoting bool
|
AnonymousVoting bool
|
||||||
AutoResetCards bool
|
AutoResetCards bool
|
||||||
@@ -22,7 +21,6 @@ func DefaultRoomSetupPageData() RoomSetupPageData {
|
|||||||
DefaultScale: "fibonacci",
|
DefaultScale: "fibonacci",
|
||||||
DefaultRevealMode: "manual",
|
DefaultRevealMode: "manual",
|
||||||
DefaultVotingTime: 0,
|
DefaultVotingTime: 0,
|
||||||
DefaultModerator: "creator",
|
|
||||||
AllowSpectators: true,
|
AllowSpectators: true,
|
||||||
AnonymousVoting: true,
|
AnonymousVoting: true,
|
||||||
AutoResetCards: true,
|
AutoResetCards: true,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"scrum-solitare/src/controllers"
|
"scrum-solitare/src/handlers"
|
||||||
"scrum-solitare/src/middleware"
|
"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)
|
registerStatic(r)
|
||||||
registerPages(r, pageController)
|
registerPages(r, pages)
|
||||||
|
registerAPI(r, rooms)
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerStatic(r *gin.Engine) {
|
func registerStatic(r *gin.Engine) {
|
||||||
@@ -20,6 +21,20 @@ func registerStatic(r *gin.Engine) {
|
|||||||
static.StaticFS("/", http.Dir("static"))
|
static.StaticFS("/", http.Dir("static"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerPages(r *gin.Engine, pageController *controllers.PageController) {
|
func registerPages(r *gin.Engine, pages *handlers.PageHandler) {
|
||||||
r.GET("/", pageController.ShowRoomSetup)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import (
|
|||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"scrum-solitare/src/controllers"
|
"scrum-solitare/src/handlers"
|
||||||
"scrum-solitare/src/routes"
|
"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 := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
|
|
||||||
r.LoadHTMLGlob("src/templates/*.html")
|
r.LoadHTMLGlob("src/templates/*.html")
|
||||||
routes.Register(r, pageController)
|
routes.Register(r, pages, rooms)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
604
src/state/manager.go
Normal file
604
src/state/manager.go
Normal file
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/state/persistence.go
Normal file
77
src/state/persistence.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
151
src/state/types.go
Normal file
151
src/state/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
83
src/state/utils.go
Normal file
83
src/state/utils.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
{{ define "body" }}
|
|
||||||
<section class="window config-window" aria-label="Room configuration">
|
|
||||||
<div class="title-bar">
|
|
||||||
<span>CreateRoom.exe</span>
|
|
||||||
<div class="title-bar-controls" aria-hidden="true">
|
|
||||||
<button type="button">_</button>
|
|
||||||
<button type="button">□</button>
|
|
||||||
<button type="button">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="window-content">
|
|
||||||
<p class="intro-copy">Configure your Scrum Poker room and share the invite link with your team.</p>
|
|
||||||
|
|
||||||
<form id="room-config-form" class="room-form" novalidate>
|
|
||||||
<div class="config-layout">
|
|
||||||
<section class="config-panel">
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="room-name">Room name</label>
|
|
||||||
<input type="text" id="room-name" name="roomName" maxlength="40" value="{{ .DefaultRoomName }}" placeholder="Sprint 32 Planning" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="username">Your username</label>
|
|
||||||
<input type="text" id="username" name="username" maxlength="32" value="{{ .DefaultUsername }}" placeholder="alice_dev" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="max-people">Max people</label>
|
|
||||||
<div class="number-input-wrap">
|
|
||||||
<input type="number" id="max-people" name="maxPeople" min="2" max="50" value="{{ .DefaultMaxPeople }}" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="estimation-scale">Estimation scale</label>
|
|
||||||
<select id="estimation-scale" name="estimationScale">
|
|
||||||
<option value="fibonacci" {{ if eq .DefaultScale "fibonacci" }}selected{{ end }}>Fibonacci (0,1,2,3,5,8,13,21,?)</option>
|
|
||||||
<option value="tshirt" {{ if eq .DefaultScale "tshirt" }}selected{{ end }}>T-Shirt (XS,S,M,L,XL,?)</option>
|
|
||||||
<option value="powers-of-two" {{ if eq .DefaultScale "powers-of-two" }}selected{{ end }}>Powers of 2 (1,2,4,8,16,32,?)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="reveal-mode">Reveal mode</label>
|
|
||||||
<select id="reveal-mode" name="revealMode">
|
|
||||||
<option value="manual" {{ if eq .DefaultRevealMode "manual" }}selected{{ end }}>Manual reveal by moderator</option>
|
|
||||||
<option value="all-voted" {{ if eq .DefaultRevealMode "all-voted" }}selected{{ end }}>Auto reveal when everyone voted</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="voting-timeout">Voting timeout (seconds)</label>
|
|
||||||
<div class="number-input-wrap number-with-unit">
|
|
||||||
<input type="number" id="voting-timeout" name="votingTimeout" min="0" max="3600" value="{{ .DefaultVotingTime }}">
|
|
||||||
<span class="input-unit">sec</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="moderator">Moderator role</label>
|
|
||||||
<select id="moderator" name="moderatorRole">
|
|
||||||
<option value="creator" {{ if eq .DefaultModerator "creator" }}selected{{ end }}>Room creator is moderator</option>
|
|
||||||
<option value="none" {{ if eq .DefaultModerator "none" }}selected{{ end }}>No fixed moderator</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset class="window options-box">
|
|
||||||
<legend>Room options</legend>
|
|
||||||
<label class="option-item">
|
|
||||||
<input type="checkbox" id="allow-spectators" name="allowSpectators" {{ if .AllowSpectators }}checked{{ end }}>
|
|
||||||
<span>Allow spectators (non-voting viewers)</span>
|
|
||||||
</label>
|
|
||||||
<label class="option-item">
|
|
||||||
<input type="checkbox" id="anonymous-voting" name="anonymousVoting" {{ if .AnonymousVoting }}checked{{ end }}>
|
|
||||||
<span>Anonymous voting until reveal</span>
|
|
||||||
</label>
|
|
||||||
<label class="option-item">
|
|
||||||
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
|
|
||||||
<span>Auto-reset cards after each reveal</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside class="window preview-window" aria-label="Room preview">
|
|
||||||
<div class="title-bar">
|
|
||||||
<span>Room Preview</span>
|
|
||||||
</div>
|
|
||||||
<div class="window-content preview-content">
|
|
||||||
<div class="preview-meta">
|
|
||||||
<span id="preview-scale">Scale: {{ .DefaultScale }}</span>
|
|
||||||
<span id="preview-max-people">Max: {{ .DefaultMaxPeople }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="preview-board" id="preview-board">
|
|
||||||
<div class="preview-cards" id="preview-cards"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-editor">
|
|
||||||
<label for="custom-card">Add card</label>
|
|
||||||
<div class="card-editor-row">
|
|
||||||
<input type="text" id="custom-card" maxlength="8" placeholder="e.g. 34 or ?">
|
|
||||||
<button type="button" id="add-card" class="btn">Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-line" id="config-status" role="status" aria-live="polite">
|
|
||||||
{{ .DefaultStatus }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions-row">
|
|
||||||
<button type="reset" class="btn">Reset</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Create Room</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{{ define "footer" }}
|
|
||||||
</main>
|
|
||||||
<footer class="taskbar" aria-hidden="true">
|
|
||||||
<div class="taskbar-start">Start</div>
|
|
||||||
<div class="taskbar-status">Scrum Poker Setup</div>
|
|
||||||
</footer>
|
|
||||||
<script src="/static/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{{ define "header" }}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Retro Scrum Poker - Room Setup</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="top-bar">
|
|
||||||
<button class="btn" id="theme-toggle">Dark Mode</button>
|
|
||||||
</div>
|
|
||||||
<main id="desktop">
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,5 +1,144 @@
|
|||||||
{{ define "index.html" }}
|
<!DOCTYPE html>
|
||||||
{{ template "header" . }}
|
<html lang="en">
|
||||||
{{ template "body" . }}
|
<head>
|
||||||
{{ template "footer" . }}
|
<meta charset="UTF-8">
|
||||||
{{ end }}
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Scrum Poker - Room Configuration</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="config">
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="btn" id="theme-toggle" type="button">Dark Mode</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="desktop">
|
||||||
|
<section class="window config-window" aria-label="Room configuration">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span>CreateRoom.exe</span>
|
||||||
|
<div class="title-bar-controls" aria-hidden="true">
|
||||||
|
<button type="button">_</button>
|
||||||
|
<button type="button">□</button>
|
||||||
|
<button type="button">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="window-content">
|
||||||
|
<p class="intro-copy">Configure your Scrum Poker room and share the invite link with your team.</p>
|
||||||
|
|
||||||
|
<form id="room-config-form" class="room-form" novalidate>
|
||||||
|
<div class="config-layout">
|
||||||
|
<section class="config-panel">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="room-name">Room name</label>
|
||||||
|
<input type="text" id="room-name" name="roomName" maxlength="80" value="{{ .DefaultRoomName }}" placeholder="Sprint 32 Planning" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="username">Your username</label>
|
||||||
|
<input type="text" id="username" name="username" maxlength="32" value="{{ .DefaultUsername }}" placeholder="alice_dev" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="max-people">Max people</label>
|
||||||
|
<div class="number-input-wrap">
|
||||||
|
<input type="number" id="max-people" name="maxPeople" min="2" max="50" value="{{ .DefaultMaxPeople }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="estimation-scale">Estimation scale</label>
|
||||||
|
<select id="estimation-scale" name="estimationScale">
|
||||||
|
<option value="fibonacci" {{ if eq .DefaultScale "fibonacci" }}selected{{ end }}>Fibonacci (0,1,2,3,5,8,13,21,?)</option>
|
||||||
|
<option value="tshirt" {{ if eq .DefaultScale "tshirt" }}selected{{ end }}>T-Shirt (XS,S,M,L,XL,?)</option>
|
||||||
|
<option value="powers-of-two" {{ if eq .DefaultScale "powers-of-two" }}selected{{ end }}>Powers of 2 (1,2,4,8,16,32,?)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="reveal-mode">Reveal mode</label>
|
||||||
|
<select id="reveal-mode" name="revealMode">
|
||||||
|
<option value="manual" {{ if eq .DefaultRevealMode "manual" }}selected{{ end }}>Manual reveal by moderator</option>
|
||||||
|
<option value="all_voted" {{ if eq .DefaultRevealMode "all_voted" }}selected{{ end }}>Auto reveal when everyone voted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="voting-timeout">Voting timeout (seconds)</label>
|
||||||
|
<div class="number-input-wrap number-with-unit">
|
||||||
|
<input type="number" id="voting-timeout" name="votingTimeoutSec" min="0" max="3600" value="{{ .DefaultVotingTime }}">
|
||||||
|
<span class="input-unit">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="room-password">Room password (optional)</label>
|
||||||
|
<input type="password" id="room-password" name="password" maxlength="64" placeholder="Optional password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="window options-box">
|
||||||
|
<legend>Room options</legend>
|
||||||
|
<label class="option-item">
|
||||||
|
<input type="checkbox" id="allow-spectators" name="allowSpectators" {{ if .AllowSpectators }}checked{{ end }}>
|
||||||
|
<span>Allow spectators (non-voting viewers)</span>
|
||||||
|
</label>
|
||||||
|
<label class="option-item">
|
||||||
|
<input type="checkbox" id="anonymous-voting" name="anonymousVoting" {{ if .AnonymousVoting }}checked{{ end }}>
|
||||||
|
<span>Anonymous voting until reveal</span>
|
||||||
|
</label>
|
||||||
|
<label class="option-item">
|
||||||
|
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
|
||||||
|
<span>Auto-reset cards after each reveal</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="window preview-window" aria-label="Room preview">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span>Room Preview</span>
|
||||||
|
</div>
|
||||||
|
<div class="window-content preview-content">
|
||||||
|
<div class="preview-meta">
|
||||||
|
<span id="preview-scale">Scale: {{ .DefaultScale }}</span>
|
||||||
|
<span id="preview-max-people">Max: {{ .DefaultMaxPeople }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-board" id="preview-board">
|
||||||
|
<div class="preview-cards" id="preview-cards"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint-text">Drag cards to reorder. Hover a card to remove it.</p>
|
||||||
|
|
||||||
|
<div class="card-editor">
|
||||||
|
<label for="custom-card">Add card</label>
|
||||||
|
<div class="card-editor-row">
|
||||||
|
<input type="text" id="custom-card" maxlength="8" placeholder="e.g. 34 or ?">
|
||||||
|
<button type="button" id="add-card" class="btn">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-line" id="config-status" role="status" aria-live="polite">{{ .DefaultStatus }}</div>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="reset" class="btn">Reset</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Room</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/js/config.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
102
src/templates/room.html
Normal file
102
src/templates/room.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Scrum Poker Room</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="room" data-room-id="{{ .RoomID }}">
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="btn" id="theme-toggle" type="button">Dark Mode</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="desktop" class="room-desktop">
|
||||||
|
<section class="room-grid" aria-label="Scrum poker room board">
|
||||||
|
<article class="window room-main-window">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span id="room-title">Room</span>
|
||||||
|
<div class="title-bar-controls" aria-hidden="true">
|
||||||
|
<button type="button">_</button>
|
||||||
|
<button type="button">□</button>
|
||||||
|
<button type="button">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-content">
|
||||||
|
<div class="room-meta">
|
||||||
|
<span id="reveal-mode-label">Reveal mode: manual</span>
|
||||||
|
<span id="round-state-label">Cards hidden</span>
|
||||||
|
</div>
|
||||||
|
<div class="voting-board" id="voting-board"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside class="window participants-window">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span>Participants</span>
|
||||||
|
</div>
|
||||||
|
<div class="window-content participants-content">
|
||||||
|
<ul id="participant-list" class="participant-list"></ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="window control-window">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span>Controls</span>
|
||||||
|
</div>
|
||||||
|
<div class="window-content control-content">
|
||||||
|
<div class="links-block">
|
||||||
|
<label>Participant Link</label>
|
||||||
|
<input id="participant-link" type="text" readonly>
|
||||||
|
<label>Admin Link</label>
|
||||||
|
<input id="admin-link" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
<div id="admin-controls" class="admin-controls hidden">
|
||||||
|
<button type="button" id="reveal-btn" class="btn">Reveal</button>
|
||||||
|
<button type="button" id="reset-btn" class="btn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<p id="room-status" class="status-line">Connecting...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="join-panel" class="window join-window hidden" aria-label="Join room">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span>JoinRoom.exe</span>
|
||||||
|
</div>
|
||||||
|
<div class="window-content">
|
||||||
|
<form id="join-form" class="room-form" novalidate>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="join-username">Username</label>
|
||||||
|
<input id="join-username" name="username" type="text" maxlength="32" required>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="join-role">Role</label>
|
||||||
|
<select id="join-role" name="role">
|
||||||
|
<option value="participant">Participant</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="join-password">Room password (if required)</label>
|
||||||
|
<input id="join-password" name="password" type="password" maxlength="64">
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="join-admin-token">Admin token (optional)</label>
|
||||||
|
<input id="join-admin-token" name="adminToken" type="text" maxlength="64">
|
||||||
|
</div>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="submit" class="btn btn-primary">Join Room</button>
|
||||||
|
</div>
|
||||||
|
<p id="join-error" class="status-line hidden"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/js/room.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
--title-text: #00ff00;
|
--title-text: #00ff00;
|
||||||
--input-bg: #111111;
|
--input-bg: #111111;
|
||||||
--status-bg: #1b1b1b;
|
--status-bg: #1b1b1b;
|
||||||
--board-bg: #0a2c14;
|
--board-bg: #0b2f16;
|
||||||
--card-bg: #171717;
|
--card-bg: #171717;
|
||||||
--card-text: #00ff66;
|
--card-text: #00ff66;
|
||||||
}
|
}
|
||||||
@@ -107,41 +107,12 @@ body {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-window {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 980px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-copy {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-form {
|
.room-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
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 {
|
.field-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -155,6 +126,7 @@ legend {
|
|||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
|
input[type="password"],
|
||||||
select {
|
select {
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
color: var(--window-text);
|
color: var(--window-text);
|
||||||
@@ -166,8 +138,7 @@ select {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"]:focus,
|
input:focus,
|
||||||
input[type="number"]:focus,
|
|
||||||
select:focus {
|
select:focus {
|
||||||
box-shadow: inset 0 0 0 1px var(--title-bg);
|
box-shadow: inset 0 0 0 1px var(--title-bg);
|
||||||
}
|
}
|
||||||
@@ -211,6 +182,76 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
text-align: right;
|
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 {
|
.options-box {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
@@ -284,8 +325,12 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5);
|
box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: transform 180ms ease, opacity 180ms ease;
|
transition: transform 180ms ease;
|
||||||
cursor: default;
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-card-remove {
|
.preview-card-remove {
|
||||||
@@ -315,8 +360,22 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-card.is-removing {
|
.preview-card.is-removing {
|
||||||
|
animation: card-pop-out 190ms ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-pop-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.55) rotate(-8deg);
|
transform: scale(0.58) rotate(-10deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-editor {
|
.card-editor {
|
||||||
@@ -335,73 +394,145 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-line {
|
/* Room page */
|
||||||
background: var(--status-bg);
|
.room-desktop {
|
||||||
border: 2px solid;
|
align-items: stretch;
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
justify-content: center;
|
||||||
padding: 5px 8px;
|
padding-top: 60px;
|
||||||
font-size: 1.1rem;
|
|
||||||
min-height: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-row {
|
.room-grid {
|
||||||
text-align: right;
|
width: min(1180px, 100%);
|
||||||
margin-top: 4px;
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"main participants"
|
||||||
|
"controls participants";
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.room-main-window {
|
||||||
background: var(--window-bg);
|
grid-area: main;
|
||||||
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 {
|
.participants-window {
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
grid-area: participants;
|
||||||
box-shadow: inset 1px 1px var(--border-mid-dark), inset -1px -1px var(--border-mid-light);
|
|
||||||
padding: 5px 11px 3px 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.control-window {
|
||||||
font-weight: bold;
|
grid-area: controls;
|
||||||
}
|
}
|
||||||
|
|
||||||
.taskbar {
|
.room-meta {
|
||||||
height: 30px;
|
|
||||||
background: var(--window-bg);
|
|
||||||
border-top: 2px solid var(--border-light);
|
|
||||||
box-shadow: inset 0 1px var(--border-mid-light);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
margin-bottom: 10px;
|
||||||
padding: 3px 6px;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.taskbar-start,
|
.voting-board {
|
||||||
.taskbar-status {
|
background: var(--board-bg);
|
||||||
border: 2px solid;
|
border: 2px solid;
|
||||||
border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light);
|
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
||||||
box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark);
|
min-height: 260px;
|
||||||
padding: 0 8px;
|
padding: 12px;
|
||||||
font-size: 1.1rem;
|
display: flex;
|
||||||
height: 20px;
|
flex-wrap: wrap;
|
||||||
display: inline-flex;
|
gap: 10px;
|
||||||
align-items: center;
|
align-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.taskbar-status {
|
.vote-card {
|
||||||
min-width: 180px;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.config-layout {
|
.config-layout,
|
||||||
|
.room-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-grid {
|
||||||
|
grid-template-areas:
|
||||||
|
"main"
|
||||||
|
"participants"
|
||||||
|
"controls";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
184
static/js/app.js
184
static/js/app.js
@@ -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();
|
|
||||||
260
static/js/config.js
Normal file
260
static/js/config.js
Normal file
@@ -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();
|
||||||
306
static/js/room.js
Normal file
306
static/js/room.js
Normal file
@@ -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();
|
||||||
Reference in New Issue
Block a user