Compare commits
10 Commits
5994e165c6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e850d3b00 | |||
| 95fde663ca | |||
| 98692359db | |||
| e4e555cac3 | |||
| 2b63370873 | |||
| 791af99662 | |||
| 3ffe0d4958 | |||
| ec8e8911ce | |||
| ffbaf0ee1d | |||
| 7299157ba9 |
@@ -23,7 +23,12 @@ COPY --from=build /app/static /app/static
|
|||||||
RUN mkdir -p /app/data && chown -R app:app /app
|
RUN mkdir -p /app/data && chown -R app:app /app
|
||||||
|
|
||||||
EXPOSE 8002
|
EXPOSE 8002
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8002
|
ENV PORT=8002
|
||||||
|
ENV MAX_ACTIVITY_LOG_ENTRIES=400
|
||||||
|
ENV ADMIN_LOG_BROADCAST_LIMIT=200
|
||||||
|
ENV STALE_ROOM_CLEANUP_INTERVAL=5m
|
||||||
|
ENV STALE_ROOM_TTL=30m
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
113
README.md
113
README.md
@@ -40,8 +40,100 @@ Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time ro
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
- `PORT`: server port (default `8002`)
|
### Quick Reference
|
||||||
- `DATA_PATH`: directory for room JSON files (default `./data`)
|
|
||||||
|
- `HOST` (default: `0.0.0.0`): network interface/address the server binds to.
|
||||||
|
- `PORT` (default: `8002`): HTTP port the server listens on.
|
||||||
|
- `DATA_PATH` (default: `./data`): folder where room JSON files are stored.
|
||||||
|
- `MAX_ACTIVITY_LOG_ENTRIES` (default: `400`): max activity entries retained per room.
|
||||||
|
- `ADMIN_LOG_BROADCAST_LIMIT` (default: `200`): max recent log entries sent to admins over SSE.
|
||||||
|
- `STALE_ROOM_CLEANUP_INTERVAL` (default: `5m`): how often stale-room cleanup runs.
|
||||||
|
- `STALE_ROOM_TTL` (default: `30m`): empty rooms older than this are deleted.
|
||||||
|
|
||||||
|
### Variable Guide With Use Cases
|
||||||
|
|
||||||
|
`HOST`
|
||||||
|
- What it controls: where the web server is reachable from.
|
||||||
|
- Use case: local-only dev on one machine.
|
||||||
|
```bash
|
||||||
|
HOST=localhost PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: allow access from LAN/Docker/k8s ingress.
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`PORT`
|
||||||
|
- What it controls: server port.
|
||||||
|
- Use case: avoid conflicts with another app already on `8002`.
|
||||||
|
```bash
|
||||||
|
PORT=9000 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: common reverse-proxy port mapping.
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=8080 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATA_PATH`
|
||||||
|
- What it controls: disk location for persisted rooms.
|
||||||
|
- Use case: keep data in a dedicated local directory.
|
||||||
|
```bash
|
||||||
|
DATA_PATH=./tmp/rooms go run ./src
|
||||||
|
```
|
||||||
|
- Use case: persistent mount in containers.
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8002:8002 -e DATA_PATH=/app/data -v $(pwd)/data:/app/data scrum-solitare
|
||||||
|
```
|
||||||
|
|
||||||
|
`MAX_ACTIVITY_LOG_ENTRIES`
|
||||||
|
- What it controls: per-room in-memory/disk activity history cap.
|
||||||
|
- Use case: retain more history for auditing/debugging.
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=1000 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: reduce memory/storage in lightweight deployments.
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=100 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`ADMIN_LOG_BROADCAST_LIMIT`
|
||||||
|
- What it controls: maximum recent activity entries included in admin SSE payloads.
|
||||||
|
- Use case: richer admin timeline in active rooms.
|
||||||
|
```bash
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=400 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: smaller SSE payloads for lower bandwidth.
|
||||||
|
```bash
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=50 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`STALE_ROOM_CLEANUP_INTERVAL`
|
||||||
|
- What it controls: scheduler frequency for checking stale empty rooms.
|
||||||
|
- Use case: aggressive cleanup in ephemeral environments.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=1m go run ./src
|
||||||
|
```
|
||||||
|
- Use case: reduce cleanup churn in stable environments.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=15m go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`STALE_ROOM_TTL`
|
||||||
|
- What it controls: time since last room activity before an empty room is deleted.
|
||||||
|
- Use case: auto-prune quickly for temporary team rooms.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_TTL=10m go run ./src
|
||||||
|
```
|
||||||
|
- Use case: keep empty rooms around longer between sessions.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_TTL=2h go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
### Duration Format Notes
|
||||||
|
|
||||||
|
`STALE_ROOM_CLEANUP_INTERVAL` and `STALE_ROOM_TTL` use Go duration syntax:
|
||||||
|
- valid examples: `30s`, `5m`, `45m`, `2h`
|
||||||
|
- invalid examples: `5` (missing unit), `five minutes`
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
@@ -52,6 +144,23 @@ go run ./src
|
|||||||
|
|
||||||
Open `http://localhost:8002`.
|
Open `http://localhost:8002`.
|
||||||
|
|
||||||
|
Host binding examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOST=localhost PORT=8002 go run ./src
|
||||||
|
HOST=0.0.0.0 PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
State tuning example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=600 \
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=300 \
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=2m \
|
||||||
|
STALE_ROOM_TTL=45m \
|
||||||
|
go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|||||||
@@ -1,13 +1,55 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Host string
|
||||||
Port string
|
Port string
|
||||||
DataPath string
|
DataPath string
|
||||||
|
MaxActivityLogEntries int
|
||||||
|
AdminLogBroadcastLimit int
|
||||||
|
StaleRoomCleanupInterval time.Duration
|
||||||
|
StaleRoomTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func envIntOrDefault(name string, fallback int) int {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDurationOrDefault(name string, fallback time.Duration) time.Duration {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
|
host := os.Getenv("HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "0.0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8002"
|
port = "8002"
|
||||||
@@ -18,8 +60,18 @@ func Load() Config {
|
|||||||
dataPath = "./data"
|
dataPath = "./data"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxActivityLogEntries := envIntOrDefault("MAX_ACTIVITY_LOG_ENTRIES", 400)
|
||||||
|
adminLogBroadcastLimit := envIntOrDefault("ADMIN_LOG_BROADCAST_LIMIT", 200)
|
||||||
|
staleRoomCleanupInterval := envDurationOrDefault("STALE_ROOM_CLEANUP_INTERVAL", 5*time.Minute)
|
||||||
|
staleRoomTTL := envDurationOrDefault("STALE_ROOM_TTL", 30*time.Minute)
|
||||||
|
|
||||||
return Config{
|
return Config{
|
||||||
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
DataPath: dataPath,
|
DataPath: dataPath,
|
||||||
|
MaxActivityLogEntries: maxActivityLogEntries,
|
||||||
|
AdminLogBroadcastLimit: adminLogBroadcastLimit,
|
||||||
|
StaleRoomCleanupInterval: staleRoomCleanupInterval,
|
||||||
|
StaleRoomTTL: staleRoomTTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type createRoomRequest struct {
|
|||||||
AllowSpectators bool `json:"allowSpectators"`
|
AllowSpectators bool `json:"allowSpectators"`
|
||||||
AnonymousVoting bool `json:"anonymousVoting"`
|
AnonymousVoting bool `json:"anonymousVoting"`
|
||||||
AutoReset bool `json:"autoReset"`
|
AutoReset bool `json:"autoReset"`
|
||||||
|
AllowVoteChange *bool `json:"allowVoteChange"`
|
||||||
RevealMode string `json:"revealMode"`
|
RevealMode string `json:"revealMode"`
|
||||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -34,6 +35,7 @@ type createRoomRequest struct {
|
|||||||
|
|
||||||
type joinRoomRequest struct {
|
type joinRoomRequest struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -42,11 +44,13 @@ type joinRoomRequest struct {
|
|||||||
|
|
||||||
type voteRequest struct {
|
type voteRequest struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
Card string `json:"card"`
|
Card string `json:"card"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminActionRequest struct {
|
type adminActionRequest struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
||||||
@@ -64,6 +68,7 @@ func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
|||||||
AllowSpectators: req.AllowSpectators,
|
AllowSpectators: req.AllowSpectators,
|
||||||
AnonymousVoting: req.AnonymousVoting,
|
AnonymousVoting: req.AnonymousVoting,
|
||||||
AutoReset: req.AutoReset,
|
AutoReset: req.AutoReset,
|
||||||
|
AllowVoteChange: req.AllowVoteChange,
|
||||||
RevealMode: req.RevealMode,
|
RevealMode: req.RevealMode,
|
||||||
VotingTimeoutSec: req.VotingTimeoutSec,
|
VotingTimeoutSec: req.VotingTimeoutSec,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
@@ -85,6 +90,7 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) {
|
|||||||
|
|
||||||
result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{
|
result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{
|
||||||
ParticipantID: req.ParticipantID,
|
ParticipantID: req.ParticipantID,
|
||||||
|
SessionToken: req.SessionToken,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Role: req.Role,
|
Role: req.Role,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
@@ -101,12 +107,17 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) {
|
|||||||
func (h *RoomAPIHandler) StreamEvents(c *gin.Context) {
|
func (h *RoomAPIHandler) StreamEvents(c *gin.Context) {
|
||||||
roomID := c.Param("roomID")
|
roomID := c.Param("roomID")
|
||||||
participantID := c.Query("participantId")
|
participantID := c.Query("participantId")
|
||||||
|
sessionToken := c.Query("sessionToken")
|
||||||
if participantID == "" {
|
if participantID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if sessionToken == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sessionToken is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID)
|
stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID, sessionToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
@@ -169,7 +180,7 @@ func (h *RoomAPIHandler) CastVote(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.Card)
|
err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.SessionToken, req.Card)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
@@ -193,21 +204,21 @@ func (h *RoomAPIHandler) LeaveRoom(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID); err != nil {
|
if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID, req.SessionToken); err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string) error) {
|
func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string, string) error) {
|
||||||
var req adminActionRequest
|
var req adminActionRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fn(c.Param("roomID"), req.ParticipantID); err != nil {
|
if err := fn(c.Param("roomID"), req.ParticipantID, req.SessionToken); err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -234,6 +245,9 @@ func (h *RoomAPIHandler) writeStateError(c *gin.Context, err error) {
|
|||||||
case errors.Is(err, state.ErrPasswordRequired):
|
case errors.Is(err, state.ErrPasswordRequired):
|
||||||
status = http.StatusUnauthorized
|
status = http.StatusUnauthorized
|
||||||
message = err.Error()
|
message = err.Error()
|
||||||
|
case errors.Is(err, state.ErrVoteChangeLocked):
|
||||||
|
status = http.StatusForbidden
|
||||||
|
message = err.Error()
|
||||||
case errors.Is(err, state.ErrSpectatorsBlocked), errors.Is(err, state.ErrInvalidCard), errors.Is(err, state.ErrInvalidRole):
|
case errors.Is(err, state.ErrSpectatorsBlocked), errors.Is(err, state.ErrInvalidCard), errors.Is(err, state.ErrInvalidRole):
|
||||||
status = http.StatusBadRequest
|
status = http.StatusBadRequest
|
||||||
message = err.Error()
|
message = err.Error()
|
||||||
|
|||||||
10
src/main.go
10
src/main.go
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
"scrum-solitare/src/config"
|
"scrum-solitare/src/config"
|
||||||
"scrum-solitare/src/handlers"
|
"scrum-solitare/src/handlers"
|
||||||
@@ -12,7 +13,12 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
manager, err := state.NewManager(cfg.DataPath)
|
manager, err := state.NewManager(cfg.DataPath, state.ManagerOptions{
|
||||||
|
MaxActivityLogEntries: cfg.MaxActivityLogEntries,
|
||||||
|
AdminLogBroadcastLimit: cfg.AdminLogBroadcastLimit,
|
||||||
|
StaleRoomCleanupInterval: cfg.StaleRoomCleanupInterval,
|
||||||
|
StaleRoomTTL: cfg.StaleRoomTTL,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize state manager: %v", err)
|
log.Fatalf("failed to initialize state manager: %v", err)
|
||||||
}
|
}
|
||||||
@@ -21,7 +27,7 @@ func main() {
|
|||||||
rooms := handlers.NewRoomAPIHandler(manager)
|
rooms := handlers.NewRoomAPIHandler(manager)
|
||||||
router := server.NewRouter(pages, rooms)
|
router := server.NewRouter(pages, rooms)
|
||||||
|
|
||||||
if err := router.Run(":" + cfg.Port); err != nil {
|
if err := router.Run(net.JoinHostPort(cfg.Host, cfg.Port)); err != nil {
|
||||||
log.Fatalf("server failed to start: %v", err)
|
log.Fatalf("server failed to start: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type RoomSetupPageData struct {
|
|||||||
AllowSpectators bool
|
AllowSpectators bool
|
||||||
AnonymousVoting bool
|
AnonymousVoting bool
|
||||||
AutoResetCards bool
|
AutoResetCards bool
|
||||||
|
AllowVoteChange bool
|
||||||
DefaultStatus string
|
DefaultStatus string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ func DefaultRoomSetupPageData() RoomSetupPageData {
|
|||||||
AllowSpectators: true,
|
AllowSpectators: true,
|
||||||
AnonymousVoting: true,
|
AnonymousVoting: true,
|
||||||
AutoResetCards: true,
|
AutoResetCards: true,
|
||||||
|
AllowVoteChange: true,
|
||||||
DefaultStatus: "Ready to create room.",
|
DefaultStatus: "Ready to create room.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,40 +3,138 @@ package state
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxActivityLogEntries = 400
|
defaultMaxActivityLogEntries = 400
|
||||||
adminLogBroadcastLimit = 200
|
defaultAdminLogBroadcastLimit = 200
|
||||||
|
defaultStaleRoomCleanupInterval = 5 * time.Minute
|
||||||
|
defaultStaleRoomTTL = 30 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]*Room
|
rooms map[string]*Room
|
||||||
store *DiskStore
|
store *DiskStore
|
||||||
|
|
||||||
|
maxActivityLogEntries int
|
||||||
|
adminLogBroadcastLimit int
|
||||||
|
staleRoomCleanupInterval time.Duration
|
||||||
|
staleRoomTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(dataPath string) (*Manager, error) {
|
type ManagerOptions struct {
|
||||||
|
MaxActivityLogEntries int
|
||||||
|
AdminLogBroadcastLimit int
|
||||||
|
StaleRoomCleanupInterval time.Duration
|
||||||
|
StaleRoomTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeManagerOptions(opts ManagerOptions) ManagerOptions {
|
||||||
|
if opts.MaxActivityLogEntries <= 0 {
|
||||||
|
opts.MaxActivityLogEntries = defaultMaxActivityLogEntries
|
||||||
|
}
|
||||||
|
if opts.AdminLogBroadcastLimit <= 0 {
|
||||||
|
opts.AdminLogBroadcastLimit = defaultAdminLogBroadcastLimit
|
||||||
|
}
|
||||||
|
if opts.StaleRoomCleanupInterval <= 0 {
|
||||||
|
opts.StaleRoomCleanupInterval = defaultStaleRoomCleanupInterval
|
||||||
|
}
|
||||||
|
if opts.StaleRoomTTL <= 0 {
|
||||||
|
opts.StaleRoomTTL = defaultStaleRoomTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(dataPath string, opts ManagerOptions) (*Manager, error) {
|
||||||
store, err := NewDiskStore(dataPath)
|
store, err := NewDiskStore(dataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
normalizedOpts := normalizeManagerOptions(opts)
|
||||||
|
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
rooms: make(map[string]*Room),
|
rooms: make(map[string]*Room),
|
||||||
store: store,
|
store: store,
|
||||||
|
maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries,
|
||||||
|
adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit,
|
||||||
|
staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval,
|
||||||
|
staleRoomTTL: normalizedOpts.StaleRoomTTL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
||||||
return nil, loadErr
|
return nil, loadErr
|
||||||
}
|
}
|
||||||
|
manager.startCleanupLoop()
|
||||||
|
|
||||||
return manager, nil
|
return manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) startCleanupLoop() {
|
||||||
|
ticker := time.NewTicker(m.staleRoomCleanupInterval)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
m.cleanupStaleRooms(nowUTC())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) cleanupStaleRooms(now time.Time) {
|
||||||
|
m.mu.RLock()
|
||||||
|
rooms := make([]*Room, 0, len(m.rooms))
|
||||||
|
for _, room := range m.rooms {
|
||||||
|
rooms = append(rooms, room)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, room := range rooms {
|
||||||
|
room.mu.Lock()
|
||||||
|
roomID := room.ID
|
||||||
|
hasConnected := hasConnectedParticipantsLocked(room)
|
||||||
|
recentlyActive := now.Sub(room.UpdatedAt) < m.staleRoomTTL
|
||||||
|
hasSubscribers := len(room.subscribers) > 0
|
||||||
|
room.mu.Unlock()
|
||||||
|
|
||||||
|
if hasConnected || recentlyActive || hasSubscribers {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
current, ok := m.rooms[roomID]
|
||||||
|
if !ok || current != room {
|
||||||
|
m.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.mu.Lock()
|
||||||
|
if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < m.staleRoomTTL || len(current.subscribers) > 0 {
|
||||||
|
current.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(m.rooms, roomID)
|
||||||
|
current.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err := m.store.Delete(roomID); err != nil {
|
||||||
|
log.Printf("failed to delete stale room %s: %v", roomID, err)
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.rooms[roomID]; !exists {
|
||||||
|
m.rooms[roomID] = room
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||||
roomName := normalizeName(input.RoomName, 80)
|
roomName := normalizeName(input.RoomName, 80)
|
||||||
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
||||||
@@ -76,6 +174,10 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
adminToken := randomHex(24)
|
adminToken := randomHex(24)
|
||||||
creatorID := newUUIDv4()
|
creatorID := newUUIDv4()
|
||||||
now := nowUTC()
|
now := nowUTC()
|
||||||
|
allowVoteChange := true
|
||||||
|
if input.AllowVoteChange != nil {
|
||||||
|
allowVoteChange = *input.AllowVoteChange
|
||||||
|
}
|
||||||
|
|
||||||
settings := RoomSettings{
|
settings := RoomSettings{
|
||||||
RoomName: roomName,
|
RoomName: roomName,
|
||||||
@@ -84,6 +186,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
AllowSpectators: input.AllowSpectators,
|
AllowSpectators: input.AllowSpectators,
|
||||||
AnonymousVoting: input.AnonymousVoting,
|
AnonymousVoting: input.AnonymousVoting,
|
||||||
AutoReset: input.AutoReset,
|
AutoReset: input.AutoReset,
|
||||||
|
AllowVoteChange: allowVoteChange,
|
||||||
RevealMode: revealMode,
|
RevealMode: revealMode,
|
||||||
VotingTimeoutSec: max(0, input.VotingTimeoutSec),
|
VotingTimeoutSec: max(0, input.VotingTimeoutSec),
|
||||||
}
|
}
|
||||||
@@ -95,7 +198,8 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
creator := &Participant{
|
creator := &Participant{
|
||||||
ID: creatorID,
|
ID: creatorID,
|
||||||
|
SessionToken: randomHex(24),
|
||||||
Username: creatorUsername,
|
Username: creatorUsername,
|
||||||
Role: RoleParticipant,
|
Role: RoleParticipant,
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
@@ -139,6 +243,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
result := CreateRoomResult{
|
result := CreateRoomResult{
|
||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
CreatorParticipantID: creatorID,
|
CreatorParticipantID: creatorID,
|
||||||
|
CreatorSessionToken: creator.SessionToken,
|
||||||
AdminToken: adminToken,
|
AdminToken: adminToken,
|
||||||
ParticipantLink: "/room/" + roomID,
|
ParticipantLink: "/room/" + roomID,
|
||||||
AdminLink: "/room/" + roomID + "?adminToken=" + adminToken,
|
AdminLink: "/room/" + roomID + "?adminToken=" + adminToken,
|
||||||
@@ -183,6 +288,9 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
if !ok {
|
if !ok {
|
||||||
return JoinRoomResult{}, ErrParticipantNotFound
|
return JoinRoomResult{}, ErrParticipantNotFound
|
||||||
}
|
}
|
||||||
|
if !secureTokenMatches(existing.SessionToken, input.SessionToken) {
|
||||||
|
return JoinRoomResult{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
wasConnected := existing.Connected
|
wasConnected := existing.Connected
|
||||||
existing.Username = username
|
existing.Username = username
|
||||||
@@ -203,6 +311,7 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
go m.broadcastRoom(room.ID)
|
go m.broadcastRoom(room.ID)
|
||||||
return JoinRoomResult{
|
return JoinRoomResult{
|
||||||
ParticipantID: existing.ID,
|
ParticipantID: existing.ID,
|
||||||
|
SessionToken: existing.SessionToken,
|
||||||
IsAdmin: existing.IsAdmin,
|
IsAdmin: existing.IsAdmin,
|
||||||
Role: existing.Role,
|
Role: existing.Role,
|
||||||
Username: existing.Username,
|
Username: existing.Username,
|
||||||
@@ -226,7 +335,8 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
}
|
}
|
||||||
|
|
||||||
participant := &Participant{
|
participant := &Participant{
|
||||||
ID: newUUIDv4(),
|
ID: newUUIDv4(),
|
||||||
|
SessionToken: randomHex(24),
|
||||||
Username: username,
|
Username: username,
|
||||||
Role: role,
|
Role: role,
|
||||||
IsAdmin: isAdminByToken,
|
IsAdmin: isAdminByToken,
|
||||||
@@ -247,13 +357,14 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
go m.broadcastRoom(room.ID)
|
go m.broadcastRoom(room.ID)
|
||||||
return JoinRoomResult{
|
return JoinRoomResult{
|
||||||
ParticipantID: participant.ID,
|
ParticipantID: participant.ID,
|
||||||
|
SessionToken: participant.SessionToken,
|
||||||
IsAdmin: participant.IsAdmin,
|
IsAdmin: participant.IsAdmin,
|
||||||
Role: participant.Role,
|
Role: participant.Role,
|
||||||
Username: participant.Username,
|
Username: participant.Username,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
func (m *Manager) LeaveRoom(roomID, participantID, sessionToken string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -262,9 +373,9 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !participant.Connected {
|
if !participant.Connected {
|
||||||
@@ -281,7 +392,7 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) CastVote(roomID, participantID, card string) error {
|
func (m *Manager) CastVote(roomID, participantID, sessionToken, card string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -290,9 +401,9 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
if participant.Role != RoleParticipant {
|
if participant.Role != RoleParticipant {
|
||||||
return ErrUnauthorized
|
return ErrUnauthorized
|
||||||
@@ -303,19 +414,26 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
|||||||
return ErrInvalidCard
|
return ErrInvalidCard
|
||||||
}
|
}
|
||||||
|
|
||||||
if room.Round.Revealed {
|
if participant.HasVoted {
|
||||||
if room.Settings.AutoReset {
|
if participant.VoteValue == normalizedCard {
|
||||||
m.resetVotesLocked(room)
|
return nil
|
||||||
} else {
|
}
|
||||||
return ErrUnauthorized
|
if !room.Settings.AllowVoteChange {
|
||||||
|
return ErrVoteChangeLocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousVote := participant.VoteValue
|
||||||
|
hadVoted := participant.HasVoted
|
||||||
participant.HasVoted = true
|
participant.HasVoted = true
|
||||||
participant.VoteValue = normalizedCard
|
participant.VoteValue = normalizedCard
|
||||||
participant.UpdatedAt = nowUTC()
|
participant.UpdatedAt = nowUTC()
|
||||||
room.UpdatedAt = nowUTC()
|
room.UpdatedAt = nowUTC()
|
||||||
m.appendActivityLogLocked(room, "%s voted %s.", participant.Username, normalizedCard)
|
if hadVoted {
|
||||||
|
m.appendActivityLogLocked(room, "%s changed vote from %s to %s.", participant.Username, previousVote, normalizedCard)
|
||||||
|
} else {
|
||||||
|
m.appendActivityLogLocked(room, "%s voted %s.", participant.Username, normalizedCard)
|
||||||
|
}
|
||||||
|
|
||||||
if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) {
|
if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) {
|
||||||
room.Round.Revealed = true
|
room.Round.Revealed = true
|
||||||
@@ -330,7 +448,7 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RevealVotes(roomID, participantID string) error {
|
func (m *Manager) RevealVotes(roomID, participantID, sessionToken string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -339,9 +457,9 @@ func (m *Manager) RevealVotes(roomID, participantID string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
if !participant.IsAdmin {
|
if !participant.IsAdmin {
|
||||||
return ErrUnauthorized
|
return ErrUnauthorized
|
||||||
@@ -359,7 +477,7 @@ func (m *Manager) RevealVotes(roomID, participantID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ResetVotes(roomID, participantID string) error {
|
func (m *Manager) ResetVotes(roomID, participantID, sessionToken string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -368,9 +486,9 @@ func (m *Manager) ResetVotes(roomID, participantID string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
if !participant.IsAdmin {
|
if !participant.IsAdmin {
|
||||||
return ErrUnauthorized
|
return ErrUnauthorized
|
||||||
@@ -388,17 +506,17 @@ func (m *Manager) ResetVotes(roomID, participantID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Subscribe(roomID, participantID string) (<-chan []byte, []byte, func(), error) {
|
func (m *Manager) Subscribe(roomID, participantID, sessionToken string) (<-chan []byte, []byte, func(), error) {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
participant, ok := room.Participants[participantID]
|
participant, authErr := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if authErr != nil {
|
||||||
room.mu.Unlock()
|
room.mu.Unlock()
|
||||||
return nil, nil, nil, ErrParticipantNotFound
|
return nil, nil, nil, authErr
|
||||||
}
|
}
|
||||||
|
|
||||||
participant.Connected = true
|
participant.Connected = true
|
||||||
@@ -466,6 +584,17 @@ func (m *Manager) getRoom(roomID string) (*Room, error) {
|
|||||||
return room, nil
|
return room, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) authorizeParticipantLocked(room *Room, participantID, sessionToken string) (*Participant, error) {
|
||||||
|
participant, ok := room.Participants[participantID]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrParticipantNotFound
|
||||||
|
}
|
||||||
|
if !secureTokenMatches(participant.SessionToken, sessionToken) {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
return participant, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) loadFromDisk() error {
|
func (m *Manager) loadFromDisk() error {
|
||||||
persistedRooms, err := m.store.LoadAll()
|
persistedRooms, err := m.store.LoadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -473,20 +602,52 @@ func (m *Manager) loadFromDisk() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, persisted := range persistedRooms {
|
for _, persisted := range persistedRooms {
|
||||||
|
allowVoteChange := true
|
||||||
|
if persisted.Settings.AllowVoteChange != nil {
|
||||||
|
allowVoteChange = *persisted.Settings.AllowVoteChange
|
||||||
|
}
|
||||||
|
settings := RoomSettings{
|
||||||
|
RoomName: persisted.Settings.RoomName,
|
||||||
|
MaxPeople: persisted.Settings.MaxPeople,
|
||||||
|
Cards: append([]string(nil), persisted.Settings.Cards...),
|
||||||
|
AllowSpectators: persisted.Settings.AllowSpectators,
|
||||||
|
AnonymousVoting: persisted.Settings.AnonymousVoting,
|
||||||
|
AutoReset: persisted.Settings.AutoReset,
|
||||||
|
AllowVoteChange: allowVoteChange,
|
||||||
|
RevealMode: persisted.Settings.RevealMode,
|
||||||
|
VotingTimeoutSec: persisted.Settings.VotingTimeoutSec,
|
||||||
|
PasswordSalt: persisted.Settings.PasswordSalt,
|
||||||
|
PasswordHash: persisted.Settings.PasswordHash,
|
||||||
|
}
|
||||||
|
|
||||||
room := &Room{
|
room := &Room{
|
||||||
ID: persisted.ID,
|
ID: persisted.ID,
|
||||||
AdminToken: persisted.AdminToken,
|
AdminToken: persisted.AdminToken,
|
||||||
CreatedAt: persisted.CreatedAt,
|
CreatedAt: persisted.CreatedAt,
|
||||||
UpdatedAt: persisted.UpdatedAt,
|
UpdatedAt: persisted.UpdatedAt,
|
||||||
Settings: persisted.Settings,
|
Settings: settings,
|
||||||
Round: persisted.Round,
|
Round: persisted.Round,
|
||||||
Participants: make(map[string]*Participant, len(persisted.Participants)),
|
Participants: make(map[string]*Participant, len(persisted.Participants)),
|
||||||
ActivityLog: append([]ActivityLogEntry(nil), persisted.ActivityLog...),
|
ActivityLog: append([]ActivityLogEntry(nil), persisted.ActivityLog...),
|
||||||
subscribers: map[string]*subscriber{},
|
subscribers: map[string]*subscriber{},
|
||||||
}
|
}
|
||||||
for _, participant := range persisted.Participants {
|
for _, participant := range persisted.Participants {
|
||||||
participant.Connected = false
|
sessionToken := participant.SessionToken
|
||||||
room.Participants[participant.ID] = participant
|
if sessionToken == "" {
|
||||||
|
sessionToken = randomHex(24)
|
||||||
|
}
|
||||||
|
room.Participants[participant.ID] = &Participant{
|
||||||
|
ID: participant.ID,
|
||||||
|
SessionToken: sessionToken,
|
||||||
|
Username: participant.Username,
|
||||||
|
Role: participant.Role,
|
||||||
|
IsAdmin: participant.IsAdmin,
|
||||||
|
Connected: false,
|
||||||
|
HasVoted: participant.HasVoted,
|
||||||
|
VoteValue: participant.VoteValue,
|
||||||
|
JoinedAt: participant.JoinedAt,
|
||||||
|
UpdatedAt: participant.UpdatedAt,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.rooms[room.ID] = room
|
m.rooms[room.ID] = room
|
||||||
@@ -496,10 +657,21 @@ func (m *Manager) loadFromDisk() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (room *Room) toPersisted() persistedRoom {
|
func (room *Room) toPersisted() persistedRoom {
|
||||||
participants := make([]*Participant, 0, len(room.Participants))
|
allowVoteChange := room.Settings.AllowVoteChange
|
||||||
|
participants := make([]*persistedParticipant, 0, len(room.Participants))
|
||||||
for _, participant := range sortParticipants(room.Participants) {
|
for _, participant := range sortParticipants(room.Participants) {
|
||||||
clone := *participant
|
participants = append(participants, &persistedParticipant{
|
||||||
participants = append(participants, &clone)
|
ID: participant.ID,
|
||||||
|
SessionToken: participant.SessionToken,
|
||||||
|
Username: participant.Username,
|
||||||
|
Role: participant.Role,
|
||||||
|
IsAdmin: participant.IsAdmin,
|
||||||
|
Connected: participant.Connected,
|
||||||
|
HasVoted: participant.HasVoted,
|
||||||
|
VoteValue: participant.VoteValue,
|
||||||
|
JoinedAt: participant.JoinedAt,
|
||||||
|
UpdatedAt: participant.UpdatedAt,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedRoom{
|
return persistedRoom{
|
||||||
@@ -507,7 +679,19 @@ func (room *Room) toPersisted() persistedRoom {
|
|||||||
AdminToken: room.AdminToken,
|
AdminToken: room.AdminToken,
|
||||||
CreatedAt: room.CreatedAt,
|
CreatedAt: room.CreatedAt,
|
||||||
UpdatedAt: room.UpdatedAt,
|
UpdatedAt: room.UpdatedAt,
|
||||||
Settings: room.Settings,
|
Settings: persistedRoomSettings{
|
||||||
|
RoomName: room.Settings.RoomName,
|
||||||
|
MaxPeople: room.Settings.MaxPeople,
|
||||||
|
Cards: append([]string(nil), room.Settings.Cards...),
|
||||||
|
AllowSpectators: room.Settings.AllowSpectators,
|
||||||
|
AnonymousVoting: room.Settings.AnonymousVoting,
|
||||||
|
AutoReset: room.Settings.AutoReset,
|
||||||
|
AllowVoteChange: &allowVoteChange,
|
||||||
|
RevealMode: room.Settings.RevealMode,
|
||||||
|
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
||||||
|
PasswordSalt: room.Settings.PasswordSalt,
|
||||||
|
PasswordHash: room.Settings.PasswordHash,
|
||||||
|
},
|
||||||
Round: room.Round,
|
Round: room.Round,
|
||||||
Participants: participants,
|
Participants: participants,
|
||||||
ActivityLog: append([]ActivityLogEntry(nil), room.ActivityLog...),
|
ActivityLog: append([]ActivityLogEntry(nil), room.ActivityLog...),
|
||||||
@@ -583,6 +767,7 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
|||||||
AllowSpectators: room.Settings.AllowSpectators,
|
AllowSpectators: room.Settings.AllowSpectators,
|
||||||
AnonymousVoting: room.Settings.AnonymousVoting,
|
AnonymousVoting: room.Settings.AnonymousVoting,
|
||||||
AutoReset: room.Settings.AutoReset,
|
AutoReset: room.Settings.AutoReset,
|
||||||
|
AllowVoteChange: room.Settings.AllowVoteChange,
|
||||||
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
||||||
Participants: participants,
|
Participants: participants,
|
||||||
SelfParticipantID: viewerParticipantID,
|
SelfParticipantID: viewerParticipantID,
|
||||||
@@ -596,8 +781,8 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
|||||||
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
||||||
|
|
||||||
start := 0
|
start := 0
|
||||||
if len(room.ActivityLog) > adminLogBroadcastLimit {
|
if len(room.ActivityLog) > m.adminLogBroadcastLimit {
|
||||||
start = len(room.ActivityLog) - adminLogBroadcastLimit
|
start = len(room.ActivityLog) - m.adminLogBroadcastLimit
|
||||||
}
|
}
|
||||||
state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start)
|
state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start)
|
||||||
for _, item := range room.ActivityLog[start:] {
|
for _, item := range room.ActivityLog[start:] {
|
||||||
@@ -647,8 +832,8 @@ func (m *Manager) appendActivityLogLocked(room *Room, format string, args ...any
|
|||||||
Message: fmt.Sprintf(format, args...),
|
Message: fmt.Sprintf(format, args...),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(room.ActivityLog) > maxActivityLogEntries {
|
if len(room.ActivityLog) > m.maxActivityLogEntries {
|
||||||
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-maxActivityLogEntries:]...)
|
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-m.maxActivityLogEntries:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,3 +842,13 @@ func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participa
|
|||||||
participant.UpdatedAt = nowUTC()
|
participant.UpdatedAt = nowUTC()
|
||||||
room.UpdatedAt = nowUTC()
|
room.UpdatedAt = nowUTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasConnectedParticipantsLocked(room *Room) bool {
|
||||||
|
for _, participant := range room.Participants {
|
||||||
|
if participant.Connected {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,3 +75,20 @@ func (ds *DiskStore) LoadAll() ([]persistedRoom, error) {
|
|||||||
|
|
||||||
return rooms, nil
|
return rooms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ds *DiskStore) Delete(roomID string) error {
|
||||||
|
ds.mu.Lock()
|
||||||
|
defer ds.mu.Unlock()
|
||||||
|
|
||||||
|
finalPath := filepath.Join(ds.dataPath, roomID+".json")
|
||||||
|
if err := os.Remove(finalPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := finalPath + ".tmp"
|
||||||
|
if err := os.Remove(tmpPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ var (
|
|||||||
ErrSpectatorsBlocked = errors.New("spectators are not allowed")
|
ErrSpectatorsBlocked = errors.New("spectators are not allowed")
|
||||||
ErrPasswordRequired = errors.New("password required or invalid")
|
ErrPasswordRequired = errors.New("password required or invalid")
|
||||||
ErrInvalidCard = errors.New("invalid card")
|
ErrInvalidCard = errors.New("invalid card")
|
||||||
|
ErrVoteChangeLocked = errors.New("vote changes are disabled for this room")
|
||||||
)
|
)
|
||||||
|
|
||||||
type RoomSettings struct {
|
type RoomSettings struct {
|
||||||
@@ -32,22 +33,51 @@ type RoomSettings struct {
|
|||||||
AllowSpectators bool `json:"allowSpectators"`
|
AllowSpectators bool `json:"allowSpectators"`
|
||||||
AnonymousVoting bool `json:"anonymousVoting"`
|
AnonymousVoting bool `json:"anonymousVoting"`
|
||||||
AutoReset bool `json:"autoReset"`
|
AutoReset bool `json:"autoReset"`
|
||||||
|
AllowVoteChange bool `json:"allowVoteChange"`
|
||||||
RevealMode string `json:"revealMode"`
|
RevealMode string `json:"revealMode"`
|
||||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||||
PasswordHash string `json:"passwordHash,omitempty"`
|
PasswordHash string `json:"passwordHash,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type persistedRoomSettings 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"`
|
||||||
|
AllowVoteChange *bool `json:"allowVoteChange,omitempty"`
|
||||||
|
RevealMode string `json:"revealMode"`
|
||||||
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||||
|
PasswordHash string `json:"passwordHash,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Participant struct {
|
type Participant struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
SessionToken string `json:"-"`
|
||||||
Role string `json:"role"`
|
Username string `json:"username"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
Role string `json:"role"`
|
||||||
Connected bool `json:"connected"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
HasVoted bool `json:"hasVoted"`
|
Connected bool `json:"connected"`
|
||||||
VoteValue string `json:"voteValue,omitempty"`
|
HasVoted bool `json:"hasVoted"`
|
||||||
JoinedAt time.Time `json:"joinedAt"`
|
VoteValue string `json:"voteValue,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
JoinedAt time.Time `json:"joinedAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type persistedParticipant struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionToken string `json:"sessionToken,omitempty"`
|
||||||
|
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 {
|
type RoundState struct {
|
||||||
@@ -64,9 +94,9 @@ type persistedRoom struct {
|
|||||||
AdminToken string `json:"adminToken"`
|
AdminToken string `json:"adminToken"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
Settings RoomSettings `json:"settings"`
|
Settings persistedRoomSettings `json:"settings"`
|
||||||
Round RoundState `json:"round"`
|
Round RoundState `json:"round"`
|
||||||
Participants []*Participant `json:"participants"`
|
Participants []*persistedParticipant `json:"participants"`
|
||||||
ActivityLog []ActivityLogEntry `json:"activityLog,omitempty"`
|
ActivityLog []ActivityLogEntry `json:"activityLog,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +127,7 @@ type CreateRoomInput struct {
|
|||||||
AllowSpectators bool
|
AllowSpectators bool
|
||||||
AnonymousVoting bool
|
AnonymousVoting bool
|
||||||
AutoReset bool
|
AutoReset bool
|
||||||
|
AllowVoteChange *bool
|
||||||
RevealMode string
|
RevealMode string
|
||||||
VotingTimeoutSec int
|
VotingTimeoutSec int
|
||||||
Password string
|
Password string
|
||||||
@@ -104,6 +135,7 @@ type CreateRoomInput struct {
|
|||||||
|
|
||||||
type JoinRoomInput struct {
|
type JoinRoomInput struct {
|
||||||
ParticipantID string
|
ParticipantID string
|
||||||
|
SessionToken string
|
||||||
Username string
|
Username string
|
||||||
Role string
|
Role string
|
||||||
Password string
|
Password string
|
||||||
@@ -113,6 +145,7 @@ type JoinRoomInput struct {
|
|||||||
type CreateRoomResult struct {
|
type CreateRoomResult struct {
|
||||||
RoomID string `json:"roomId"`
|
RoomID string `json:"roomId"`
|
||||||
CreatorParticipantID string `json:"creatorParticipantId"`
|
CreatorParticipantID string `json:"creatorParticipantId"`
|
||||||
|
CreatorSessionToken string `json:"creatorSessionToken"`
|
||||||
AdminToken string `json:"adminToken"`
|
AdminToken string `json:"adminToken"`
|
||||||
ParticipantLink string `json:"participantLink"`
|
ParticipantLink string `json:"participantLink"`
|
||||||
AdminLink string `json:"adminLink"`
|
AdminLink string `json:"adminLink"`
|
||||||
@@ -120,6 +153,7 @@ type CreateRoomResult struct {
|
|||||||
|
|
||||||
type JoinRoomResult struct {
|
type JoinRoomResult struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -155,6 +189,7 @@ type PublicRoomState struct {
|
|||||||
AllowSpectators bool `json:"allowSpectators"`
|
AllowSpectators bool `json:"allowSpectators"`
|
||||||
AnonymousVoting bool `json:"anonymousVoting"`
|
AnonymousVoting bool `json:"anonymousVoting"`
|
||||||
AutoReset bool `json:"autoReset"`
|
AutoReset bool `json:"autoReset"`
|
||||||
|
AllowVoteChange bool `json:"allowVoteChange"`
|
||||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
Participants []PublicParticipant `json:"participants"`
|
Participants []PublicParticipant `json:"participants"`
|
||||||
SelfParticipantID string `json:"selfParticipantId"`
|
SelfParticipantID string `json:"selfParticipantId"`
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ func passwordMatches(password, salt, expectedHash string) bool {
|
|||||||
return subtle.ConstantTimeCompare([]byte(computed), []byte(expectedHash)) == 1
|
return subtle.ConstantTimeCompare([]byte(computed), []byte(expectedHash)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func secureTokenMatches(expected, provided string) bool {
|
||||||
|
if expected == "" || provided == "" || len(expected) != len(provided) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
func nowUTC() time.Time {
|
func nowUTC() time.Time {
|
||||||
return time.Now().UTC()
|
return time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,20 @@
|
|||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label for="room-password">Room password (optional)</label>
|
<label for="room-password">Room password (optional)</label>
|
||||||
<input type="password" id="room-password" name="password" maxlength="64" placeholder="Optional password">
|
<input
|
||||||
|
type="password"
|
||||||
|
id="room-password"
|
||||||
|
name="password"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="Optional password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-bwignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,6 +117,10 @@
|
|||||||
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
|
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
|
||||||
<span>Auto-reset cards after each reveal</span>
|
<span>Auto-reset cards after each reveal</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="option-item">
|
||||||
|
<input type="checkbox" id="allow-vote-change" name="allowVoteChange" {{ if .AllowVoteChange }}checked{{ end }}>
|
||||||
|
<span>Allow participants to change their vote</span>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -184,17 +201,17 @@
|
|||||||
aria-modal="false"
|
aria-modal="false"
|
||||||
aria-labelledby="theme-tool-title"
|
aria-labelledby="theme-tool-title"
|
||||||
data-ui-window
|
data-ui-window
|
||||||
data-window-title="ThemePicker.exe"
|
data-window-title="Theme.exe"
|
||||||
data-window-rights="all"
|
data-window-rights="all"
|
||||||
data-window-order="10"
|
data-window-order="10"
|
||||||
data-window-default-left="16"
|
data-window-default-left="16"
|
||||||
data-window-default-top="88"
|
data-window-default-top="88"
|
||||||
data-window-default-width="390"
|
data-window-default-width="390"
|
||||||
data-window-default-height="250"
|
data-window-default-height="320"
|
||||||
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png","modern":"/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png"}'
|
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png","modern":"/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png"}'
|
||||||
>
|
>
|
||||||
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
|
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
|
||||||
<span id="theme-tool-title">ThemePicker.exe</span>
|
<span id="theme-tool-title">Theme.exe</span>
|
||||||
<div class="title-bar-controls">
|
<div class="title-bar-controls">
|
||||||
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,32 +232,7 @@
|
|||||||
<span>No Theme</span>
|
<span>No Theme</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="tool-copy">Set the display mode.</p>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
id="mode-tool-window"
|
|
||||||
class="window ui-tool-window hidden"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="false"
|
|
||||||
aria-labelledby="mode-tool-title"
|
|
||||||
data-ui-window
|
|
||||||
data-window-title="DisplayMode.exe"
|
|
||||||
data-window-rights="all"
|
|
||||||
data-window-order="20"
|
|
||||||
data-window-default-left="424"
|
|
||||||
data-window-default-top="88"
|
|
||||||
data-window-default-width="340"
|
|
||||||
data-window-default-height="190"
|
|
||||||
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png","modern":"/static/img/Windows Icons - PNG/desk.cpl_14_100-0.png","none":"/static/img/Windows Icons - PNG/timedate.cpl_14_200-6.png","default":"/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png"}'
|
|
||||||
>
|
|
||||||
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
|
|
||||||
<span id="mode-tool-title">DisplayMode.exe</span>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button type="button" data-role="close-window" data-target="mode-tool-window">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-content">
|
|
||||||
<p class="tool-copy" id="mode-status-text">Current mode: Light</p>
|
<p class="tool-copy" id="mode-status-text">Current mode: Light</p>
|
||||||
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
|
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
|
||||||
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
|
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
|
||||||
|
|||||||
@@ -17,16 +17,7 @@
|
|||||||
<body data-page="room" data-room-id="{{ .RoomID }}" class="prejoin">
|
<body data-page="room" data-room-id="{{ .RoomID }}" class="prejoin">
|
||||||
<div class="mobile-control-strip">
|
<div class="mobile-control-strip">
|
||||||
<div class="taskbar-shell">
|
<div class="taskbar-shell">
|
||||||
<div class="taskbar-program-list">
|
<div class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||||
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="theme-tool-window" aria-label="Open theme picker">
|
|
||||||
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_109-1.png" alt="">
|
|
||||||
<span>ThemePicker.exe</span>
|
|
||||||
</button>
|
|
||||||
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="mode-tool-window" aria-label="Open display mode settings">
|
|
||||||
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
|
|
||||||
<span>DisplayMode.exe</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,7 +115,6 @@
|
|||||||
<div id="admin-controls" class="admin-controls hidden">
|
<div id="admin-controls" class="admin-controls hidden">
|
||||||
<button type="button" id="reveal-btn" class="btn">Reveal</button>
|
<button type="button" id="reveal-btn" class="btn">Reveal</button>
|
||||||
<button type="button" id="reset-btn" class="btn">Reset</button>
|
<button type="button" id="reset-btn" class="btn">Reset</button>
|
||||||
<button type="button" id="terminal-btn" class="btn">Terminal</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="room-message" class="status-line">Waiting for join...</p>
|
<p id="room-message" class="status-line">Waiting for join...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,23 +155,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="terminal-modal-overlay" class="terminal-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="terminal-title">
|
<section
|
||||||
<section class="window terminal-window">
|
id="theme-tool-window"
|
||||||
<div class="title-bar">
|
class="window ui-tool-window hidden"
|
||||||
<span id="terminal-title">RoomTerminal.exe</span>
|
role="dialog"
|
||||||
<div class="title-bar-controls">
|
aria-modal="false"
|
||||||
<button type="button" id="terminal-close-btn">×</button>
|
aria-labelledby="theme-tool-title"
|
||||||
</div>
|
data-ui-window
|
||||||
</div>
|
data-window-title="Theme.exe"
|
||||||
<div class="window-content terminal-window-content">
|
data-window-rights="all"
|
||||||
<div id="terminal-log-output" class="terminal-log-output" aria-live="polite"></div>
|
data-window-order="10"
|
||||||
</div>
|
data-window-default-left="16"
|
||||||
</section>
|
data-window-default-top="88"
|
||||||
</div>
|
data-window-default-width="390"
|
||||||
|
data-window-default-height="320"
|
||||||
<section id="theme-tool-window" class="window ui-tool-window hidden" role="dialog" aria-modal="false" aria-labelledby="theme-tool-title">
|
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png","modern":"/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png"}'
|
||||||
|
>
|
||||||
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
|
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
|
||||||
<span id="theme-tool-title">ThemePicker.exe</span>
|
<span id="theme-tool-title">Theme.exe</span>
|
||||||
<div class="title-bar-controls">
|
<div class="title-bar-controls">
|
||||||
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,17 +193,7 @@
|
|||||||
<span>No Theme</span>
|
<span>No Theme</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="tool-copy">Set the display mode.</p>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="mode-tool-window" class="window ui-tool-window hidden" role="dialog" aria-modal="false" aria-labelledby="mode-tool-title">
|
|
||||||
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
|
|
||||||
<span id="mode-tool-title">DisplayMode.exe</span>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button type="button" data-role="close-window" data-target="mode-tool-window">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-content">
|
|
||||||
<p class="tool-copy" id="mode-status-text">Current mode: Light</p>
|
<p class="tool-copy" id="mode-status-text">Current mode: Light</p>
|
||||||
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
|
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
|
||||||
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
|
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
|
||||||
@@ -220,20 +201,38 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="terminal-tool-window"
|
||||||
|
class="window ui-tool-window terminal-window hidden"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-labelledby="terminal-title"
|
||||||
|
data-ui-window
|
||||||
|
data-window-title="Terminal.exe"
|
||||||
|
data-window-rights="admin"
|
||||||
|
data-window-order="30"
|
||||||
|
data-window-default-left="780"
|
||||||
|
data-window-default-top="88"
|
||||||
|
data-window-default-width="500"
|
||||||
|
data-window-default-height="350"
|
||||||
|
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/taskmgr.exe_14_107-1.png","modern":"/static/img/Windows Icons - PNG/taskmgr.exe_14_137.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/taskmgr.exe_14_107-1.png"}'
|
||||||
|
>
|
||||||
|
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
|
||||||
|
<span id="terminal-title">Terminal.exe</span>
|
||||||
|
<div class="title-bar-controls">
|
||||||
|
<button type="button" data-role="close-window" data-target="terminal-tool-window">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-content terminal-window-content">
|
||||||
|
<div id="terminal-log-output" class="terminal-log-output" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="taskbar desktop-taskbar" aria-label="Desktop taskbar">
|
<footer class="taskbar desktop-taskbar" aria-label="Desktop taskbar">
|
||||||
<div class="taskbar-shell">
|
<div class="taskbar-shell">
|
||||||
<div class="taskbar-program-list">
|
<div class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||||
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="theme-tool-window" aria-label="Open theme picker">
|
|
||||||
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_109-1.png" alt="">
|
|
||||||
<span>ThemePicker.exe</span>
|
|
||||||
</button>
|
|
||||||
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="mode-tool-window" aria-label="Open display mode settings">
|
|
||||||
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
|
|
||||||
<span>DisplayMode.exe</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,17 @@ body.is-dragging-window .ui-tool-title-bar {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-panel-window {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-window > .window-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.participants-window {
|
.participants-window {
|
||||||
min-height: 24rem;
|
min-height: 24rem;
|
||||||
}
|
}
|
||||||
@@ -421,12 +432,13 @@ body.is-dragging-window .ui-tool-title-bar {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.participants-scroll {
|
.participants-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 13rem;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,25 +511,23 @@ body.is-dragging-window .ui-tool-title-bar {
|
|||||||
width: min(27rem, 92vw);
|
width: min(27rem, 92vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 72;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-window {
|
.terminal-window {
|
||||||
width: min(46rem, 94vw);
|
width: min(46rem, 94vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-window-content {
|
.terminal-window-content {
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-log-output {
|
.terminal-log-output {
|
||||||
height: min(55vh, 30rem);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const USERNAME_KEY = 'scrumPoker.username';
|
const USERNAME_KEY = 'scrumPoker.username';
|
||||||
const PRESETS_KEY = 'scrumPoker.deckPresets.v1';
|
const PRESETS_KEY = 'scrumPoker.deckPresets.v1';
|
||||||
|
const ROOM_SESSION_KEY_PREFIX = 'scrumPoker.roomSession.';
|
||||||
|
|
||||||
const SCALE_PRESETS = {
|
const SCALE_PRESETS = {
|
||||||
fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'],
|
fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'],
|
||||||
@@ -502,6 +503,7 @@ roomConfigForm.addEventListener('submit', async (event) => {
|
|||||||
allowSpectators: Boolean(formData.get('allowSpectators')),
|
allowSpectators: Boolean(formData.get('allowSpectators')),
|
||||||
anonymousVoting: Boolean(formData.get('anonymousVoting')),
|
anonymousVoting: Boolean(formData.get('anonymousVoting')),
|
||||||
autoReset: Boolean(formData.get('autoReset')),
|
autoReset: Boolean(formData.get('autoReset')),
|
||||||
|
allowVoteChange: Boolean(formData.get('allowVoteChange')),
|
||||||
revealMode: (formData.get('revealMode') || 'manual').toString(),
|
revealMode: (formData.get('revealMode') || 'manual').toString(),
|
||||||
votingTimeoutSec: Number(formData.get('votingTimeoutSec') || 0),
|
votingTimeoutSec: Number(formData.get('votingTimeoutSec') || 0),
|
||||||
password: (formData.get('password') || '').toString(),
|
password: (formData.get('password') || '').toString(),
|
||||||
@@ -522,7 +524,12 @@ roomConfigForm.addEventListener('submit', async (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = `/room/${encodeURIComponent(data.roomId)}?participantId=${encodeURIComponent(data.creatorParticipantId)}&adminToken=${encodeURIComponent(data.adminToken)}&username=${encodeURIComponent(payload.creatorUsername)}`;
|
localStorage.setItem(`${ROOM_SESSION_KEY_PREFIX}${data.roomId}`, JSON.stringify({
|
||||||
|
participantId: data.creatorParticipantId,
|
||||||
|
sessionToken: data.creatorSessionToken,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const target = `/room/${encodeURIComponent(data.roomId)}?adminToken=${encodeURIComponent(data.adminToken)}&username=${encodeURIComponent(payload.creatorUsername)}`;
|
||||||
window.location.assign(target);
|
window.location.assign(target);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
statusLine.textContent = 'Network error while creating room.';
|
statusLine.textContent = 'Network error while creating room.';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const USERNAME_KEY = 'scrumPoker.username';
|
const USERNAME_KEY = 'scrumPoker.username';
|
||||||
|
const ROOM_SESSION_KEY_PREFIX = 'scrumPoker.roomSession.';
|
||||||
|
|
||||||
const roomID = document.body.dataset.roomId;
|
const roomID = document.body.dataset.roomId;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -16,15 +17,13 @@ const participantList = document.getElementById('participant-list');
|
|||||||
const adminControls = document.getElementById('admin-controls');
|
const adminControls = document.getElementById('admin-controls');
|
||||||
const revealBtn = document.getElementById('reveal-btn');
|
const revealBtn = document.getElementById('reveal-btn');
|
||||||
const resetBtn = document.getElementById('reset-btn');
|
const resetBtn = document.getElementById('reset-btn');
|
||||||
const terminalBtn = document.getElementById('terminal-btn');
|
|
||||||
const shareLinkInput = document.getElementById('share-link');
|
const shareLinkInput = document.getElementById('share-link');
|
||||||
const shareAdminToggle = document.getElementById('share-admin-toggle');
|
const shareAdminToggle = document.getElementById('share-admin-toggle');
|
||||||
const votesCounter = document.getElementById('votes-counter');
|
const votesCounter = document.getElementById('votes-counter');
|
||||||
const roomMessage = document.getElementById('room-message');
|
const roomMessage = document.getElementById('room-message');
|
||||||
const changeNameBtn = document.getElementById('change-name-btn');
|
const changeNameBtn = document.getElementById('change-name-btn');
|
||||||
const terminalModalOverlay = document.getElementById('terminal-modal-overlay');
|
|
||||||
const terminalCloseBtn = document.getElementById('terminal-close-btn');
|
|
||||||
const terminalLogOutput = document.getElementById('terminal-log-output');
|
const terminalLogOutput = document.getElementById('terminal-log-output');
|
||||||
|
const TERMINAL_WINDOW_ID = 'terminal-tool-window';
|
||||||
|
|
||||||
const joinPanel = document.getElementById('join-panel');
|
const joinPanel = document.getElementById('join-panel');
|
||||||
const joinForm = document.getElementById('join-form');
|
const joinForm = document.getElementById('join-form');
|
||||||
@@ -34,6 +33,7 @@ const joinPasswordInput = document.getElementById('join-password');
|
|||||||
const joinAdminTokenInput = document.getElementById('join-admin-token');
|
const joinAdminTokenInput = document.getElementById('join-admin-token');
|
||||||
const joinError = document.getElementById('join-error');
|
const joinError = document.getElementById('join-error');
|
||||||
let participantID = params.get('participantId') || '';
|
let participantID = params.get('participantId') || '';
|
||||||
|
let sessionToken = params.get('sessionToken') || '';
|
||||||
let adminToken = params.get('adminToken') || '';
|
let adminToken = params.get('adminToken') || '';
|
||||||
const prefillUsername = params.get('username') || '';
|
const prefillUsername = params.get('username') || '';
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
@@ -45,6 +45,45 @@ const savedUsername = localStorage.getItem(USERNAME_KEY) || '';
|
|||||||
joinUsernameInput.value = prefillUsername || savedUsername;
|
joinUsernameInput.value = prefillUsername || savedUsername;
|
||||||
joinAdminTokenInput.value = adminToken;
|
joinAdminTokenInput.value = adminToken;
|
||||||
|
|
||||||
|
function roomSessionStorageKey() {
|
||||||
|
return `${ROOM_SESSION_KEY_PREFIX}${roomID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRoomSession() {
|
||||||
|
if (!participantID || !sessionToken) {
|
||||||
|
localStorage.removeItem(roomSessionStorageKey());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(roomSessionStorageKey(), JSON.stringify({
|
||||||
|
participantId: participantID,
|
||||||
|
sessionToken,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRoomSessionFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(roomSessionStorageKey());
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!participantID && typeof parsed.participantId === 'string') {
|
||||||
|
participantID = parsed.participantId;
|
||||||
|
}
|
||||||
|
if (!sessionToken && typeof parsed.sessionToken === 'string') {
|
||||||
|
sessionToken = parsed.sessionToken;
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
localStorage.removeItem(roomSessionStorageKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!participantID || !sessionToken) {
|
||||||
|
loadRoomSessionFromStorage();
|
||||||
|
}
|
||||||
|
persistRoomSession();
|
||||||
|
|
||||||
if (!window.CardUI || typeof window.CardUI.appendFace !== 'function') {
|
if (!window.CardUI || typeof window.CardUI.appendFace !== 'function') {
|
||||||
throw new Error('CardUI is not loaded. Ensure /static/js/cards.js is included before room.js.');
|
throw new Error('CardUI is not loaded. Ensure /static/js/cards.js is included before room.js.');
|
||||||
}
|
}
|
||||||
@@ -64,12 +103,8 @@ function setJoinError(message) {
|
|||||||
function updateURL() {
|
function updateURL() {
|
||||||
const next = new URL(window.location.href);
|
const next = new URL(window.location.href);
|
||||||
next.searchParams.delete('username');
|
next.searchParams.delete('username');
|
||||||
|
next.searchParams.delete('participantId');
|
||||||
if (participantID) {
|
next.searchParams.delete('sessionToken');
|
||||||
next.searchParams.set('participantId', participantID);
|
|
||||||
} else {
|
|
||||||
next.searchParams.delete('participantId');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminToken) {
|
if (adminToken) {
|
||||||
next.searchParams.set('adminToken', adminToken);
|
next.searchParams.set('adminToken', adminToken);
|
||||||
@@ -92,11 +127,15 @@ function setRoomMessage(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function joinRoom({ username, role, password, participantIdOverride }) {
|
async function joinRoom({ username, role, password, participantIdOverride }) {
|
||||||
|
const activeParticipantID = participantIdOverride || participantID;
|
||||||
|
const rejoinParticipantID = activeParticipantID && sessionToken ? activeParticipantID : '';
|
||||||
|
|
||||||
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, {
|
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
participantId: participantIdOverride || participantID,
|
participantId: rejoinParticipantID,
|
||||||
|
sessionToken,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
password,
|
password,
|
||||||
@@ -110,7 +149,9 @@ async function joinRoom({ username, role, password, participantIdOverride }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
participantID = data.participantId;
|
participantID = data.participantId;
|
||||||
|
sessionToken = data.sessionToken;
|
||||||
localStorage.setItem(USERNAME_KEY, data.username);
|
localStorage.setItem(USERNAME_KEY, data.username);
|
||||||
|
persistRoomSession();
|
||||||
updateURL();
|
updateURL();
|
||||||
setJoinError('');
|
setJoinError('');
|
||||||
return data;
|
return data;
|
||||||
@@ -162,7 +203,9 @@ function parseNumericVote(value) {
|
|||||||
|
|
||||||
function calculateSummary(state) {
|
function calculateSummary(state) {
|
||||||
const rows = new Map();
|
const rows = new Map();
|
||||||
const numericVotes = [];
|
const scoreVotes = [];
|
||||||
|
const cardIndexByValue = new Map(state.cards.map((cardValue, index) => [cardValue, index]));
|
||||||
|
let hasNumericVote = false;
|
||||||
|
|
||||||
state.participants.forEach((participant) => {
|
state.participants.forEach((participant) => {
|
||||||
if (!participant.connected) {
|
if (!participant.connected) {
|
||||||
@@ -180,26 +223,42 @@ function calculateSummary(state) {
|
|||||||
|
|
||||||
const numeric = parseNumericVote(participant.voteValue);
|
const numeric = parseNumericVote(participant.voteValue);
|
||||||
if (numeric !== null) {
|
if (numeric !== null) {
|
||||||
numericVotes.push(numeric);
|
hasNumericVote = true;
|
||||||
|
scoreVotes.push(numeric);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardIndexByValue.has(participant.voteValue)) {
|
||||||
|
scoreVotes.push(cardIndexByValue.get(participant.voteValue));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let average = null;
|
let average = null;
|
||||||
if (numericVotes.length > 0) {
|
if (scoreVotes.length > 0) {
|
||||||
average = numericVotes.reduce((acc, value) => acc + value, 0) / numericVotes.length;
|
average = scoreVotes.reduce((acc, value) => acc + value, 0) / scoreVotes.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deckNumeric = state.cards
|
let averageCard = null;
|
||||||
.map(parseNumericVote)
|
|
||||||
.filter((value) => value !== null)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
let recommended = null;
|
let recommended = null;
|
||||||
if (average !== null && deckNumeric.length > 0) {
|
if (!hasNumericVote && average !== null && state.cards.length > 0) {
|
||||||
recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1];
|
const roundedIndex = Math.min(
|
||||||
|
state.cards.length - 1,
|
||||||
|
Math.max(0, Math.round(average)),
|
||||||
|
);
|
||||||
|
averageCard = state.cards[roundedIndex];
|
||||||
|
recommended = state.cards[roundedIndex];
|
||||||
|
} else {
|
||||||
|
const deckNumeric = state.cards
|
||||||
|
.map(parseNumericVote)
|
||||||
|
.filter((value) => value !== null)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (average !== null && deckNumeric.length > 0) {
|
||||||
|
recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rows, average, recommended };
|
return { rows, average, recommended, averageCard };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(state) {
|
function renderSummary(state) {
|
||||||
@@ -210,7 +269,7 @@ function renderSummary(state) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, average, recommended } = calculateSummary(state);
|
const { rows, average, recommended, averageCard } = calculateSummary(state);
|
||||||
summaryBody.innerHTML = '';
|
summaryBody.innerHTML = '';
|
||||||
|
|
||||||
if (rows.size === 0) {
|
if (rows.size === 0) {
|
||||||
@@ -233,13 +292,17 @@ function renderSummary(state) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
summaryAverage.textContent = average === null ? 'Average: -' : `Average: ${average.toFixed(2)}`;
|
if (averageCard !== null) {
|
||||||
|
summaryAverage.textContent = `Average: ${averageCard}`;
|
||||||
|
} else {
|
||||||
|
summaryAverage.textContent = average === null ? 'Average: -' : `Average: ${average.toFixed(2)}`;
|
||||||
|
}
|
||||||
summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`;
|
summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCards(cards, participants, isRevealed) {
|
function renderCards(cards, participants, isRevealed, allowVoteChange) {
|
||||||
const self = participants.find((participant) => participant.id === participantID && participant.connected);
|
const self = participants.find((participant) => participant.id === participantID && participant.connected);
|
||||||
const canVote = self && self.role === 'participant';
|
const canVote = self && self.role === 'participant' && (allowVoteChange || !self.hasVoted);
|
||||||
const selfVote = self ? self.voteValue : '';
|
const selfVote = self ? self.voteValue : '';
|
||||||
|
|
||||||
votingBoard.innerHTML = '';
|
votingBoard.innerHTML = '';
|
||||||
@@ -315,22 +378,14 @@ function renderTerminalLogs(logs) {
|
|||||||
terminalLogOutput.scrollTop = terminalLogOutput.scrollHeight;
|
terminalLogOutput.scrollTop = terminalLogOutput.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTerminal() {
|
|
||||||
terminalModalOverlay.classList.remove('hidden');
|
|
||||||
renderTerminalLogs(latestAdminLogs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeTerminal() {
|
|
||||||
terminalModalOverlay.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderState(state) {
|
function renderState(state) {
|
||||||
roomTitle.textContent = `${state.roomName} (${state.roomId})`;
|
roomTitle.textContent = `${state.roomName} (${state.roomId})`;
|
||||||
revealModeLabel.textContent = `Reveal mode: ${state.revealMode}`;
|
revealModeLabel.textContent = `Reveal mode: ${state.revealMode}`;
|
||||||
roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden';
|
roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden';
|
||||||
|
|
||||||
renderParticipants(state.participants, state.revealed);
|
renderParticipants(state.participants, state.revealed);
|
||||||
renderCards(state.cards, state.participants, state.revealed);
|
const allowVoteChange = state.allowVoteChange !== false;
|
||||||
|
renderCards(state.cards, state.participants, state.revealed, allowVoteChange);
|
||||||
renderSummary(state);
|
renderSummary(state);
|
||||||
|
|
||||||
const self = state.participants.find((participant) => participant.id === participantID && participant.connected);
|
const self = state.participants.find((participant) => participant.id === participantID && participant.connected);
|
||||||
@@ -341,16 +396,18 @@ function renderState(state) {
|
|||||||
latestLinks = state.links || { participantLink: '', adminLink: '' };
|
latestLinks = state.links || { participantLink: '', adminLink: '' };
|
||||||
updateShareLink();
|
updateShareLink();
|
||||||
|
|
||||||
|
if (typeof window.setUIWindowAccess === 'function') {
|
||||||
|
window.setUIWindowAccess({ admin: state.viewerIsAdmin });
|
||||||
|
}
|
||||||
|
|
||||||
if (state.viewerIsAdmin) {
|
if (state.viewerIsAdmin) {
|
||||||
adminControls.classList.remove('hidden');
|
adminControls.classList.remove('hidden');
|
||||||
terminalBtn.classList.remove('hidden');
|
|
||||||
} else {
|
} else {
|
||||||
adminControls.classList.add('hidden');
|
adminControls.classList.add('hidden');
|
||||||
terminalBtn.classList.add('hidden');
|
|
||||||
closeTerminal();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
latestAdminLogs = Array.isArray(state.adminLogs) ? state.adminLogs : [];
|
latestAdminLogs = Array.isArray(state.adminLogs) ? state.adminLogs : [];
|
||||||
if (state.viewerIsAdmin && !terminalModalOverlay.classList.contains('hidden')) {
|
if (state.viewerIsAdmin && typeof window.isUIWindowOpen === 'function' && window.isUIWindowOpen(TERMINAL_WINDOW_ID)) {
|
||||||
renderTerminalLogs(latestAdminLogs);
|
renderTerminalLogs(latestAdminLogs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,12 +422,40 @@ function updateShareLink() {
|
|||||||
shareLinkInput.value = raw ? `${window.location.origin}${raw}` : '';
|
shareLinkInput.value = raw ? `${window.location.origin}${raw}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackCopyFromShareInput() {
|
||||||
|
shareLinkInput.focus();
|
||||||
|
shareLinkInput.select();
|
||||||
|
shareLinkInput.setSelectionRange(0, shareLinkInput.value.length);
|
||||||
|
document.execCommand('copy');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAndCopyShareLink() {
|
||||||
|
if (!shareLinkInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareLinkInput.focus();
|
||||||
|
shareLinkInput.select();
|
||||||
|
shareLinkInput.setSelectionRange(0, shareLinkInput.value.length);
|
||||||
|
|
||||||
|
if (!navigator.clipboard || !window.isSecureContext) {
|
||||||
|
fallbackCopyFromShareInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareLinkInput.value);
|
||||||
|
} catch (_err) {
|
||||||
|
fallbackCopyFromShareInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource = new EventSource(`/api/rooms/${encodeURIComponent(roomID)}/events?participantId=${encodeURIComponent(participantID)}`);
|
eventSource = new EventSource(`/api/rooms/${encodeURIComponent(roomID)}/events?participantId=${encodeURIComponent(participantID)}&sessionToken=${encodeURIComponent(sessionToken)}`);
|
||||||
eventSource.addEventListener('state', (event) => {
|
eventSource.addEventListener('state', (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data);
|
const payload = JSON.parse(event.data);
|
||||||
@@ -388,7 +473,7 @@ function connectSSE() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function castVote(card) {
|
async function castVote(card) {
|
||||||
if (!participantID) {
|
if (!participantID || !sessionToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +481,7 @@ async function castVote(card) {
|
|||||||
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/vote`, {
|
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/vote`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ participantId: participantID, card }),
|
body: JSON.stringify({ participantId: participantID, sessionToken, card }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -409,7 +494,7 @@ async function castVote(card) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function adminAction(action) {
|
async function adminAction(action) {
|
||||||
if (!participantID) {
|
if (!participantID || !sessionToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +502,7 @@ async function adminAction(action) {
|
|||||||
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/${action}`, {
|
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ participantId: participantID }),
|
body: JSON.stringify({ participantId: participantID, sessionToken }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -430,7 +515,7 @@ async function adminAction(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function changeName() {
|
async function changeName() {
|
||||||
if (!participantID) {
|
if (!participantID || !sessionToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,21 +551,24 @@ async function changeName() {
|
|||||||
|
|
||||||
revealBtn.addEventListener('click', () => adminAction('reveal'));
|
revealBtn.addEventListener('click', () => adminAction('reveal'));
|
||||||
resetBtn.addEventListener('click', () => adminAction('reset'));
|
resetBtn.addEventListener('click', () => adminAction('reset'));
|
||||||
terminalBtn.addEventListener('click', openTerminal);
|
|
||||||
terminalCloseBtn.addEventListener('click', closeTerminal);
|
|
||||||
terminalModalOverlay.addEventListener('click', (event) => {
|
|
||||||
if (event.target === terminalModalOverlay) {
|
|
||||||
closeTerminal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
shareAdminToggle.addEventListener('change', updateShareLink);
|
shareAdminToggle.addEventListener('change', updateShareLink);
|
||||||
|
shareLinkInput.addEventListener('click', () => {
|
||||||
|
void selectAndCopyShareLink();
|
||||||
|
});
|
||||||
changeNameBtn.addEventListener('click', () => {
|
changeNameBtn.addEventListener('click', () => {
|
||||||
void changeName();
|
void changeName();
|
||||||
});
|
});
|
||||||
window.addEventListener('keydown', (event) => {
|
document.addEventListener('click', (event) => {
|
||||||
if (event.key === 'Escape') {
|
const openBtn = event.target.closest('[data-role="open-window"]');
|
||||||
closeTerminal();
|
if (!openBtn || openBtn.dataset.target !== TERMINAL_WINDOW_ID) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (typeof window.isUIWindowOpen === 'function' && window.isUIWindowOpen(TERMINAL_WINDOW_ID)) {
|
||||||
|
renderTerminalLogs(latestAdminLogs);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
joinForm.addEventListener('submit', async (event) => {
|
joinForm.addEventListener('submit', async (event) => {
|
||||||
@@ -495,21 +583,18 @@ joinForm.addEventListener('submit', async (event) => {
|
|||||||
adminToken = joinAdminTokenInput.value.trim();
|
adminToken = joinAdminTokenInput.value.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await joinRoom({
|
await joinRoom({
|
||||||
username,
|
username,
|
||||||
role: joinRoleInput.value,
|
role: joinRoleInput.value,
|
||||||
password: joinPasswordInput.value,
|
password: joinPasswordInput.value,
|
||||||
participantIdOverride: participantID,
|
participantIdOverride: participantID,
|
||||||
});
|
});
|
||||||
if (result.isAdmin) {
|
|
||||||
const adminRoomURL = `/room/${encodeURIComponent(roomID)}?participantId=${encodeURIComponent(participantID)}&adminToken=${encodeURIComponent(adminToken)}`;
|
|
||||||
window.location.assign(adminRoomURL);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
connectSSE();
|
connectSSE();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (participantID) {
|
if (participantID || sessionToken) {
|
||||||
participantID = '';
|
participantID = '';
|
||||||
|
sessionToken = '';
|
||||||
|
persistRoomSession();
|
||||||
updateURL();
|
updateURL();
|
||||||
}
|
}
|
||||||
setJoinError(err.message);
|
setJoinError(err.message);
|
||||||
@@ -517,7 +602,7 @@ joinForm.addEventListener('submit', async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function tryAutoJoinExistingParticipant() {
|
async function tryAutoJoinExistingParticipant() {
|
||||||
if (!participantID) {
|
if (!participantID || !sessionToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,16 +618,18 @@ async function tryAutoJoinExistingParticipant() {
|
|||||||
connectSSE();
|
connectSSE();
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
participantID = '';
|
participantID = '';
|
||||||
|
sessionToken = '';
|
||||||
|
persistRoomSession();
|
||||||
updateURL();
|
updateURL();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('pagehide', () => {
|
window.addEventListener('pagehide', () => {
|
||||||
if (!participantID) {
|
if (!participantID || !sessionToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = JSON.stringify({ participantId: participantID });
|
const payload = JSON.stringify({ participantId: participantID, sessionToken });
|
||||||
navigator.sendBeacon(`/api/rooms/${encodeURIComponent(roomID)}/leave`, new Blob([payload], { type: 'application/json' }));
|
navigator.sendBeacon(`/api/rooms/${encodeURIComponent(roomID)}/leave`, new Blob([payload], { type: 'application/json' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,11 @@
|
|||||||
const DEFAULT_THEME = 'win98';
|
const DEFAULT_THEME = 'win98';
|
||||||
const MODE_ICON_LIGHT = '/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png';
|
const MODE_ICON_LIGHT = '/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png';
|
||||||
const MODE_ICON_DARK = '/static/img/Windows Icons - PNG/desk.cpl_14_40-6.png';
|
const MODE_ICON_DARK = '/static/img/Windows Icons - PNG/desk.cpl_14_40-6.png';
|
||||||
const DEFAULT_WINDOW_LAYOUTS = {
|
|
||||||
'theme-tool-window': { left: 16, top: 88, width: 390, height: 250 },
|
|
||||||
'mode-tool-window': { left: 424, top: 88, width: 340, height: 190 },
|
|
||||||
};
|
|
||||||
let floatingWindowZ = 80;
|
let floatingWindowZ = 80;
|
||||||
let windowLayouts = {};
|
let windowLayouts = {};
|
||||||
|
let windowDefs = [];
|
||||||
|
let accessState = { admin: false };
|
||||||
|
|
||||||
function isMobileViewport() {
|
function isMobileViewport() {
|
||||||
return window.matchMedia('(max-width: 899px)').matches;
|
return window.matchMedia('(max-width: 899px)').matches;
|
||||||
@@ -29,58 +28,41 @@
|
|||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentTheme() {
|
||||||
|
return document.documentElement.getAttribute('data-ui-theme') || DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentMode() {
|
function getCurrentMode() {
|
||||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWindowOpen(id) {
|
|
||||||
const win = document.getElementById(id);
|
|
||||||
return Boolean(win && !win.classList.contains('hidden'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncTaskButtons() {
|
|
||||||
document.querySelectorAll('[data-role="open-window"]').forEach((button) => {
|
|
||||||
const target = button.dataset.target;
|
|
||||||
button.classList.toggle('is-active', isWindowOpen(target));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncControls() {
|
|
||||||
const theme = document.documentElement.getAttribute('data-ui-theme') || DEFAULT_THEME;
|
|
||||||
const mode = getCurrentMode();
|
|
||||||
const modeLabel = mode === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode';
|
|
||||||
const modeIcon = mode === 'dark' ? MODE_ICON_DARK : MODE_ICON_LIGHT;
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
|
|
||||||
const selected = button.dataset.theme === theme;
|
|
||||||
button.classList.toggle('is-selected', selected);
|
|
||||||
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-role="mode-toggle-label"]').forEach((el) => {
|
|
||||||
el.textContent = modeLabel;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-role="mode-icon"]').forEach((el) => {
|
|
||||||
el.src = modeIcon;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('#mode-status-text').forEach((el) => {
|
|
||||||
el.textContent = `Current mode: ${mode === 'dark' ? 'Dark' : 'Light'}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
syncTaskButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bringWindowToFront(windowEl) {
|
|
||||||
floatingWindowZ += 1;
|
|
||||||
windowEl.style.zIndex = String(floatingWindowZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
function clamp(value, min, max) {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOwn(obj, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSON(value, fallback) {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_err) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWindowAccess(def) {
|
||||||
|
const rights = def.rights || 'all';
|
||||||
|
if (rights === 'admin') {
|
||||||
|
return Boolean(accessState.admin);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function loadWindowLayouts() {
|
function loadWindowLayouts() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(WINDOW_LAYOUTS_KEY);
|
const raw = localStorage.getItem(WINDOW_LAYOUTS_KEY);
|
||||||
@@ -88,10 +70,7 @@
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (!parsed || typeof parsed !== 'object') {
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -130,6 +109,7 @@
|
|||||||
const height = clamp(layout.height, minHeight, maxHeight);
|
const height = clamp(layout.height, minHeight, maxHeight);
|
||||||
const left = clamp(layout.left, margin, Math.max(margin, window.innerWidth - width - margin));
|
const left = clamp(layout.left, margin, Math.max(margin, window.innerWidth - width - margin));
|
||||||
const top = clamp(layout.top, margin, Math.max(margin, window.innerHeight - height - margin));
|
const top = clamp(layout.top, margin, Math.max(margin, window.innerHeight - height - margin));
|
||||||
|
|
||||||
return { left, top, width, height };
|
return { left, top, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,94 +133,282 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bringWindowToFront(windowEl) {
|
||||||
|
floatingWindowZ += 1;
|
||||||
|
windowEl.style.zIndex = String(floatingWindowZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultLayout(el, index) {
|
||||||
|
const step = 28 * index;
|
||||||
|
return normalizeLayout({
|
||||||
|
left: Number(el.dataset.windowDefaultLeft) || 16 + step,
|
||||||
|
top: Number(el.dataset.windowDefaultTop) || 88 + step,
|
||||||
|
width: Number(el.dataset.windowDefaultWidth) || 360,
|
||||||
|
height: Number(el.dataset.windowDefaultHeight) || 220,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function persistWindowLayout(windowEl) {
|
function persistWindowLayout(windowEl) {
|
||||||
const id = windowEl.id;
|
const id = windowEl.id;
|
||||||
if (!id) {
|
if (!id || isMobileViewport()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = clampLayoutToViewport(readLayoutFromDOM(windowEl));
|
windowLayouts[id] = clampLayoutToViewport(readLayoutFromDOM(windowEl));
|
||||||
windowLayouts[id] = next;
|
|
||||||
saveWindowLayouts();
|
saveWindowLayouts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureWindowLayout(windowEl) {
|
function measureLayoutFromContent(def) {
|
||||||
const id = windowEl.id;
|
const windowEl = def.el;
|
||||||
if (!id) {
|
const fallback = def.defaultLayout;
|
||||||
return;
|
const wasHidden = windowEl.classList.contains('hidden');
|
||||||
|
const previousStyles = {
|
||||||
|
visibility: windowEl.style.visibility,
|
||||||
|
left: windowEl.style.left,
|
||||||
|
top: windowEl.style.top,
|
||||||
|
width: windowEl.style.width,
|
||||||
|
height: windowEl.style.height,
|
||||||
|
right: windowEl.style.right,
|
||||||
|
bottom: windowEl.style.bottom,
|
||||||
|
transform: windowEl.style.transform,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wasHidden) {
|
||||||
|
windowEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
windowEl.style.visibility = 'hidden';
|
||||||
|
windowEl.style.left = '-10000px';
|
||||||
|
windowEl.style.top = '-10000px';
|
||||||
|
windowEl.style.width = 'max-content';
|
||||||
|
windowEl.style.height = 'auto';
|
||||||
|
windowEl.style.right = 'auto';
|
||||||
|
windowEl.style.bottom = 'auto';
|
||||||
|
windowEl.style.transform = 'none';
|
||||||
|
|
||||||
|
const rect = windowEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
windowEl.style.visibility = previousStyles.visibility;
|
||||||
|
windowEl.style.left = previousStyles.left;
|
||||||
|
windowEl.style.top = previousStyles.top;
|
||||||
|
windowEl.style.width = previousStyles.width;
|
||||||
|
windowEl.style.height = previousStyles.height;
|
||||||
|
windowEl.style.right = previousStyles.right;
|
||||||
|
windowEl.style.bottom = previousStyles.bottom;
|
||||||
|
windowEl.style.transform = previousStyles.transform;
|
||||||
|
|
||||||
|
if (wasHidden) {
|
||||||
|
windowEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeLayout({
|
||||||
|
left: fallback.left,
|
||||||
|
top: fallback.top,
|
||||||
|
width: Number.isFinite(rect.width) && rect.width > 0 ? Math.ceil(rect.width) : fallback.width,
|
||||||
|
height: Number.isFinite(rect.height) && rect.height > 0 ? Math.ceil(rect.height) : fallback.height,
|
||||||
|
}, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindowLayout(def, options = {}) {
|
||||||
if (isMobileViewport()) {
|
if (isMobileViewport()) {
|
||||||
windowEl.style.right = 'auto';
|
def.el.style.right = 'auto';
|
||||||
windowEl.style.bottom = 'auto';
|
def.el.style.bottom = 'auto';
|
||||||
windowEl.style.transform = 'none';
|
def.el.style.transform = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const defaults = DEFAULT_WINDOW_LAYOUTS[id];
|
|
||||||
const saved = windowLayouts[id];
|
const hasSavedLayout = hasOwn(options, 'hasSavedLayout')
|
||||||
const normalized = normalizeLayout(saved, defaults);
|
? options.hasSavedLayout
|
||||||
|
: hasOwn(windowLayouts, def.id);
|
||||||
|
const normalized = hasSavedLayout
|
||||||
|
? normalizeLayout(windowLayouts[def.id], def.defaultLayout)
|
||||||
|
: measureLayoutFromContent(def);
|
||||||
const clamped = clampLayoutToViewport(normalized);
|
const clamped = clampLayoutToViewport(normalized);
|
||||||
applyLayout(windowEl, clamped);
|
applyLayout(def.el, clamped);
|
||||||
windowLayouts[id] = clamped;
|
windowLayouts[def.id] = clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openToolWindow(id) {
|
function isWindowOpen(id) {
|
||||||
const windowEl = document.getElementById(id);
|
const def = windowDefs.find((item) => item.id === id);
|
||||||
if (!windowEl) {
|
return Boolean(def && !def.el.classList.contains('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWindowById(id) {
|
||||||
|
const def = windowDefs.find((item) => item.id === id);
|
||||||
|
if (!def) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ensureWindowLayout(windowEl);
|
def.el.classList.add('hidden');
|
||||||
windowEl.classList.remove('hidden');
|
|
||||||
bringWindowToFront(windowEl);
|
|
||||||
syncTaskButtons();
|
syncTaskButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeToolWindow(id) {
|
function openWindowById(id) {
|
||||||
const windowEl = document.getElementById(id);
|
const def = windowDefs.find((item) => item.id === id);
|
||||||
if (!windowEl) {
|
if (!def || !hasWindowAccess(def)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
windowEl.classList.add('hidden');
|
ensureWindowLayout(def);
|
||||||
|
def.el.classList.remove('hidden');
|
||||||
|
bringWindowToFront(def.el);
|
||||||
syncTaskButtons();
|
syncTaskButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWindowIcon(def, theme) {
|
||||||
|
return def.icons[theme] || def.icons.default || '/static/img/Windows Icons - PNG/main.cpl_14_109-1.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskbarButtons() {
|
||||||
|
const theme = getCurrentTheme();
|
||||||
|
const visibleDefs = windowDefs
|
||||||
|
.filter(hasWindowAccess)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
const html = visibleDefs.map((def) => {
|
||||||
|
const icon = resolveWindowIcon(def, theme);
|
||||||
|
const safeTitle = def.title;
|
||||||
|
return [
|
||||||
|
`<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="${def.id}" aria-label="Open ${safeTitle}">`,
|
||||||
|
`<img class="taskbar-icon" src="${icon}" alt="">`,
|
||||||
|
`<span>${safeTitle}</span>`,
|
||||||
|
'</button>',
|
||||||
|
].join('');
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-role="taskbar-program-list"]').forEach((container) => {
|
||||||
|
container.innerHTML = html;
|
||||||
|
});
|
||||||
|
|
||||||
|
syncTaskButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTaskButtons() {
|
||||||
|
document.querySelectorAll('[data-role="open-window"]').forEach((button) => {
|
||||||
|
const target = button.dataset.target;
|
||||||
|
button.classList.toggle('is-active', isWindowOpen(target));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncControls() {
|
||||||
|
const theme = getCurrentTheme();
|
||||||
|
const mode = getCurrentMode();
|
||||||
|
const modeLabel = mode === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode';
|
||||||
|
const modeIcon = mode === 'dark' ? MODE_ICON_DARK : MODE_ICON_LIGHT;
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
|
||||||
|
const selected = button.dataset.theme === theme;
|
||||||
|
button.classList.toggle('is-selected', selected);
|
||||||
|
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-role="mode-toggle-label"]').forEach((el) => {
|
||||||
|
el.textContent = modeLabel;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-role="mode-icon"]').forEach((el) => {
|
||||||
|
el.src = modeIcon;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('#mode-status-text').forEach((el) => {
|
||||||
|
el.textContent = `Current mode: ${mode === 'dark' ? 'Dark' : 'Light'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTaskbarButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindowDefinitions() {
|
||||||
|
const windows = Array.from(document.querySelectorAll('[data-ui-window]'));
|
||||||
|
windowDefs = windows.map((el, index) => ({
|
||||||
|
id: el.id,
|
||||||
|
el,
|
||||||
|
title: el.dataset.windowTitle || el.id,
|
||||||
|
rights: el.dataset.windowRights || 'all',
|
||||||
|
order: Number(el.dataset.windowOrder) || (index + 1) * 10,
|
||||||
|
icons: parseJSON(el.dataset.windowIcons, {}),
|
||||||
|
defaultLayout: getDefaultLayout(el, index),
|
||||||
|
})).filter((def) => Boolean(def.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWindowAccess(nextAccess) {
|
||||||
|
accessState = {
|
||||||
|
...accessState,
|
||||||
|
...(nextAccess || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
windowDefs.forEach((def) => {
|
||||||
|
if (!hasWindowAccess(def)) {
|
||||||
|
def.el.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
syncControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWindowLayouts() {
|
||||||
|
windowLayouts = loadWindowLayouts();
|
||||||
|
let shouldPersistSeededLayouts = false;
|
||||||
|
|
||||||
|
windowDefs.forEach((def) => {
|
||||||
|
const hasSavedLayout = hasOwn(windowLayouts, def.id);
|
||||||
|
ensureWindowLayout(def, { hasSavedLayout });
|
||||||
|
if (!hasSavedLayout) {
|
||||||
|
shouldPersistSeededLayouts = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldPersistSeededLayouts) {
|
||||||
|
saveWindowLayouts();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (isMobileViewport()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowDefs.forEach((def) => {
|
||||||
|
const normalized = normalizeLayout(windowLayouts[def.id], def.defaultLayout);
|
||||||
|
const clamped = clampLayoutToViewport(normalized);
|
||||||
|
applyLayout(def.el, clamped);
|
||||||
|
windowLayouts[def.id] = clamped;
|
||||||
|
});
|
||||||
|
|
||||||
|
saveWindowLayouts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initDraggableWindows() {
|
function initDraggableWindows() {
|
||||||
let dragState = null;
|
let dragState = null;
|
||||||
|
|
||||||
document.querySelectorAll('[data-role="drag-handle"]').forEach((handle) => {
|
document.addEventListener('pointerdown', (event) => {
|
||||||
handle.addEventListener('pointerdown', (event) => {
|
const handle = event.target.closest('[data-role="drag-handle"]');
|
||||||
if (event.button !== 0) {
|
if (!handle || event.button !== 0 || event.target.closest('button')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.target.closest('button')) {
|
if (isMobileViewport()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowEl = handle.closest('.ui-tool-window');
|
const windowEl = handle.closest('.ui-tool-window[data-ui-window]');
|
||||||
if (!windowEl || windowEl.classList.contains('hidden')) {
|
if (!windowEl || windowEl.classList.contains('hidden')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isMobileViewport()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bringWindowToFront(windowEl);
|
bringWindowToFront(windowEl);
|
||||||
const rect = windowEl.getBoundingClientRect();
|
const rect = windowEl.getBoundingClientRect();
|
||||||
windowEl.style.left = `${rect.left}px`;
|
windowEl.style.left = `${rect.left}px`;
|
||||||
windowEl.style.top = `${rect.top}px`;
|
windowEl.style.top = `${rect.top}px`;
|
||||||
windowEl.style.right = 'auto';
|
windowEl.style.right = 'auto';
|
||||||
windowEl.style.bottom = 'auto';
|
windowEl.style.bottom = 'auto';
|
||||||
windowEl.style.transform = 'none';
|
windowEl.style.transform = 'none';
|
||||||
|
|
||||||
dragState = {
|
dragState = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
windowEl,
|
windowEl,
|
||||||
offsetX: event.clientX - rect.left,
|
offsetX: event.clientX - rect.left,
|
||||||
offsetY: event.clientY - rect.top,
|
offsetY: event.clientY - rect.top,
|
||||||
};
|
};
|
||||||
|
|
||||||
document.body.classList.add('is-dragging-window');
|
document.body.classList.add('is-dragging-window');
|
||||||
handle.setPointerCapture(event.pointerId);
|
handle.setPointerCapture(event.pointerId);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('pointermove', (event) => {
|
window.addEventListener('pointermove', (event) => {
|
||||||
@@ -279,59 +447,64 @@
|
|||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const windowEl = entry.target;
|
const windowEl = entry.target;
|
||||||
if (windowEl.classList.contains('hidden')) {
|
if (windowEl.classList.contains('hidden') || isMobileViewport()) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isMobileViewport()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
persistWindowLayout(windowEl);
|
persistWindowLayout(windowEl);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
windowDefs.forEach((def) => observer.observe(def.el));
|
||||||
observer.observe(windowEl);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initWindowLayouts() {
|
function initTaskbarAndWindowEvents() {
|
||||||
windowLayouts = loadWindowLayouts();
|
document.addEventListener('click', (event) => {
|
||||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
const openBtn = event.target.closest('[data-role="open-window"]');
|
||||||
ensureWindowLayout(windowEl);
|
if (openBtn) {
|
||||||
});
|
const target = openBtn.dataset.target;
|
||||||
|
if (!target) {
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (isMobileViewport()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
|
||||||
const id = windowEl.id;
|
|
||||||
if (!id) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = normalizeLayout(windowLayouts[id], DEFAULT_WINDOW_LAYOUTS[id]);
|
if (isWindowOpen(target)) {
|
||||||
const clamped = clampLayoutToViewport(normalized);
|
closeWindowById(target);
|
||||||
applyLayout(windowEl, clamped);
|
} else {
|
||||||
windowLayouts[id] = clamped;
|
openWindowById(target);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBtn = event.target.closest('[data-role="close-window"]');
|
||||||
|
if (!closeBtn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = closeBtn.dataset.target || closeBtn.closest('.ui-tool-window')?.id;
|
||||||
|
if (target) {
|
||||||
|
closeWindowById(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
windowDefs.forEach((def) => {
|
||||||
|
def.el.addEventListener('pointerdown', () => {
|
||||||
|
if (def.el.classList.contains('hidden')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bringWindowToFront(def.el);
|
||||||
});
|
});
|
||||||
saveWindowLayouts();
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Escape') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
windowDefs.forEach((def) => {
|
||||||
|
def.el.classList.add('hidden');
|
||||||
|
});
|
||||||
|
syncTaskButtons();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initUIControls() {
|
function initThemeAndModeHandlers() {
|
||||||
if (window.__uiControlsInitialized) {
|
|
||||||
syncControls();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.__uiControlsInitialized = true;
|
|
||||||
|
|
||||||
const savedTheme = localStorage.getItem(THEME_KEY) || DEFAULT_THEME;
|
|
||||||
const savedMode = localStorage.getItem(MODE_KEY) || 'light';
|
|
||||||
applyTheme(savedTheme);
|
|
||||||
applyMode(savedMode);
|
|
||||||
initWindowLayouts();
|
|
||||||
syncControls();
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
|
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const value = button.dataset.theme || DEFAULT_THEME;
|
const value = button.dataset.theme || DEFAULT_THEME;
|
||||||
@@ -349,58 +522,34 @@
|
|||||||
syncControls();
|
syncControls();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('[data-role="open-window"]').forEach((button) => {
|
function initUIControls() {
|
||||||
button.addEventListener('click', () => {
|
if (window.__uiControlsInitialized) {
|
||||||
const target = button.dataset.target;
|
syncControls();
|
||||||
if (!target) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
window.__uiControlsInitialized = true;
|
||||||
if (isWindowOpen(target)) {
|
|
||||||
closeToolWindow(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openToolWindow(target);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-role="close-window"]').forEach((button) => {
|
const savedTheme = localStorage.getItem(THEME_KEY) || DEFAULT_THEME;
|
||||||
button.addEventListener('click', () => {
|
const savedMode = localStorage.getItem(MODE_KEY) || 'light';
|
||||||
const target = button.dataset.target;
|
applyTheme(savedTheme);
|
||||||
if (!target) {
|
applyMode(savedMode);
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeToolWindow(target);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
|
||||||
windowEl.addEventListener('pointerdown', () => {
|
|
||||||
if (windowEl.classList.contains('hidden')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bringWindowToFront(windowEl);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key !== 'Escape') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
|
||||||
if (windowEl.classList.contains('hidden')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
windowEl.classList.add('hidden');
|
|
||||||
});
|
|
||||||
syncTaskButtons();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
collectWindowDefinitions();
|
||||||
|
initWindowLayouts();
|
||||||
|
initTaskbarAndWindowEvents();
|
||||||
|
initThemeAndModeHandlers();
|
||||||
initDraggableWindows();
|
initDraggableWindows();
|
||||||
initResizableWindows();
|
initResizableWindows();
|
||||||
|
syncControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.initUIControls = initUIControls;
|
window.initUIControls = initUIControls;
|
||||||
|
window.isUIWindowOpen = isWindowOpen;
|
||||||
|
window.openUIWindow = openWindowById;
|
||||||
|
window.closeUIWindow = closeWindowById;
|
||||||
|
window.setUIWindowAccess = setWindowAccess;
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initUIControls, { once: true });
|
document.addEventListener('DOMContentLoaded', initUIControls, { once: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user