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
|
||||
|
||||
EXPOSE 8002
|
||||
ENV HOST=0.0.0.0
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
- `PORT`: server port (default `8002`)
|
||||
- `DATA_PATH`: directory for room JSON files (default `./data`)
|
||||
### Quick Reference
|
||||
|
||||
- `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
|
||||
|
||||
@@ -52,6 +144,23 @@ go run ./src
|
||||
|
||||
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
|
||||
|
||||
Build:
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port 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 {
|
||||
host := os.Getenv("HOST")
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8002"
|
||||
@@ -18,8 +60,18 @@ func Load() Config {
|
||||
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{
|
||||
Host: host,
|
||||
Port: port,
|
||||
DataPath: dataPath,
|
||||
MaxActivityLogEntries: maxActivityLogEntries,
|
||||
AdminLogBroadcastLimit: adminLogBroadcastLimit,
|
||||
StaleRoomCleanupInterval: staleRoomCleanupInterval,
|
||||
StaleRoomTTL: staleRoomTTL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type createRoomRequest struct {
|
||||
AllowSpectators bool `json:"allowSpectators"`
|
||||
AnonymousVoting bool `json:"anonymousVoting"`
|
||||
AutoReset bool `json:"autoReset"`
|
||||
AllowVoteChange *bool `json:"allowVoteChange"`
|
||||
RevealMode string `json:"revealMode"`
|
||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||
Password string `json:"password"`
|
||||
@@ -34,6 +35,7 @@ type createRoomRequest struct {
|
||||
|
||||
type joinRoomRequest struct {
|
||||
ParticipantID string `json:"participantId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
@@ -42,11 +44,13 @@ type joinRoomRequest struct {
|
||||
|
||||
type voteRequest struct {
|
||||
ParticipantID string `json:"participantId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Card string `json:"card"`
|
||||
}
|
||||
|
||||
type adminActionRequest struct {
|
||||
ParticipantID string `json:"participantId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
}
|
||||
|
||||
func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
||||
@@ -64,6 +68,7 @@ func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
||||
AllowSpectators: req.AllowSpectators,
|
||||
AnonymousVoting: req.AnonymousVoting,
|
||||
AutoReset: req.AutoReset,
|
||||
AllowVoteChange: req.AllowVoteChange,
|
||||
RevealMode: req.RevealMode,
|
||||
VotingTimeoutSec: req.VotingTimeoutSec,
|
||||
Password: req.Password,
|
||||
@@ -85,6 +90,7 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) {
|
||||
|
||||
result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{
|
||||
ParticipantID: req.ParticipantID,
|
||||
SessionToken: req.SessionToken,
|
||||
Username: req.Username,
|
||||
Role: req.Role,
|
||||
Password: req.Password,
|
||||
@@ -101,12 +107,17 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) {
|
||||
func (h *RoomAPIHandler) StreamEvents(c *gin.Context) {
|
||||
roomID := c.Param("roomID")
|
||||
participantID := c.Query("participantId")
|
||||
sessionToken := c.Query("sessionToken")
|
||||
if participantID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"})
|
||||
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 {
|
||||
h.writeStateError(c, err)
|
||||
return
|
||||
@@ -169,7 +180,7 @@ func (h *RoomAPIHandler) CastVote(c *gin.Context) {
|
||||
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 {
|
||||
h.writeStateError(c, err)
|
||||
return
|
||||
@@ -193,21 +204,21 @@ func (h *RoomAPIHandler) LeaveRoom(c *gin.Context) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
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
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := fn(c.Param("roomID"), req.ParticipantID); err != nil {
|
||||
if err := fn(c.Param("roomID"), req.ParticipantID, req.SessionToken); err != nil {
|
||||
h.writeStateError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -234,6 +245,9 @@ func (h *RoomAPIHandler) writeStateError(c *gin.Context, err error) {
|
||||
case errors.Is(err, state.ErrPasswordRequired):
|
||||
status = http.StatusUnauthorized
|
||||
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):
|
||||
status = http.StatusBadRequest
|
||||
message = err.Error()
|
||||
|
||||
10
src/main.go
10
src/main.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"scrum-solitare/src/config"
|
||||
"scrum-solitare/src/handlers"
|
||||
@@ -12,7 +13,12 @@ import (
|
||||
func main() {
|
||||
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 {
|
||||
log.Fatalf("failed to initialize state manager: %v", err)
|
||||
}
|
||||
@@ -21,7 +27,7 @@ func main() {
|
||||
rooms := handlers.NewRoomAPIHandler(manager)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type RoomSetupPageData struct {
|
||||
AllowSpectators bool
|
||||
AnonymousVoting bool
|
||||
AutoResetCards bool
|
||||
AllowVoteChange bool
|
||||
DefaultStatus string
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ func DefaultRoomSetupPageData() RoomSetupPageData {
|
||||
AllowSpectators: true,
|
||||
AnonymousVoting: true,
|
||||
AutoResetCards: true,
|
||||
AllowVoteChange: true,
|
||||
DefaultStatus: "Ready to create room.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,40 +3,138 @@ package state
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxActivityLogEntries = 400
|
||||
adminLogBroadcastLimit = 200
|
||||
defaultMaxActivityLogEntries = 400
|
||||
defaultAdminLogBroadcastLimit = 200
|
||||
defaultStaleRoomCleanupInterval = 5 * time.Minute
|
||||
defaultStaleRoomTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]*Room
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedOpts := normalizeManagerOptions(opts)
|
||||
|
||||
manager := &Manager{
|
||||
rooms: make(map[string]*Room),
|
||||
store: store,
|
||||
maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries,
|
||||
adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit,
|
||||
staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval,
|
||||
staleRoomTTL: normalizedOpts.StaleRoomTTL,
|
||||
}
|
||||
|
||||
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
manager.startCleanupLoop()
|
||||
|
||||
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) {
|
||||
roomName := normalizeName(input.RoomName, 80)
|
||||
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
||||
@@ -76,6 +174,10 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||
adminToken := randomHex(24)
|
||||
creatorID := newUUIDv4()
|
||||
now := nowUTC()
|
||||
allowVoteChange := true
|
||||
if input.AllowVoteChange != nil {
|
||||
allowVoteChange = *input.AllowVoteChange
|
||||
}
|
||||
|
||||
settings := RoomSettings{
|
||||
RoomName: roomName,
|
||||
@@ -84,6 +186,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||
AllowSpectators: input.AllowSpectators,
|
||||
AnonymousVoting: input.AnonymousVoting,
|
||||
AutoReset: input.AutoReset,
|
||||
AllowVoteChange: allowVoteChange,
|
||||
RevealMode: revealMode,
|
||||
VotingTimeoutSec: max(0, input.VotingTimeoutSec),
|
||||
}
|
||||
@@ -95,7 +198,8 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||
}
|
||||
|
||||
creator := &Participant{
|
||||
ID: creatorID,
|
||||
ID: creatorID,
|
||||
SessionToken: randomHex(24),
|
||||
Username: creatorUsername,
|
||||
Role: RoleParticipant,
|
||||
IsAdmin: true,
|
||||
@@ -139,6 +243,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||
result := CreateRoomResult{
|
||||
RoomID: roomID,
|
||||
CreatorParticipantID: creatorID,
|
||||
CreatorSessionToken: creator.SessionToken,
|
||||
AdminToken: adminToken,
|
||||
ParticipantLink: "/room/" + roomID,
|
||||
AdminLink: "/room/" + roomID + "?adminToken=" + adminToken,
|
||||
@@ -183,6 +288,9 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
||||
if !ok {
|
||||
return JoinRoomResult{}, ErrParticipantNotFound
|
||||
}
|
||||
if !secureTokenMatches(existing.SessionToken, input.SessionToken) {
|
||||
return JoinRoomResult{}, ErrUnauthorized
|
||||
}
|
||||
|
||||
wasConnected := existing.Connected
|
||||
existing.Username = username
|
||||
@@ -203,6 +311,7 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
||||
go m.broadcastRoom(room.ID)
|
||||
return JoinRoomResult{
|
||||
ParticipantID: existing.ID,
|
||||
SessionToken: existing.SessionToken,
|
||||
IsAdmin: existing.IsAdmin,
|
||||
Role: existing.Role,
|
||||
Username: existing.Username,
|
||||
@@ -226,7 +335,8 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
||||
}
|
||||
|
||||
participant := &Participant{
|
||||
ID: newUUIDv4(),
|
||||
ID: newUUIDv4(),
|
||||
SessionToken: randomHex(24),
|
||||
Username: username,
|
||||
Role: role,
|
||||
IsAdmin: isAdminByToken,
|
||||
@@ -247,13 +357,14 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
||||
go m.broadcastRoom(room.ID)
|
||||
return JoinRoomResult{
|
||||
ParticipantID: participant.ID,
|
||||
SessionToken: participant.SessionToken,
|
||||
IsAdmin: participant.IsAdmin,
|
||||
Role: participant.Role,
|
||||
Username: participant.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
||||
func (m *Manager) LeaveRoom(roomID, participantID, sessionToken string) error {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -262,9 +373,9 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !participant.Connected {
|
||||
@@ -281,7 +392,7 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -290,9 +401,9 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if participant.Role != RoleParticipant {
|
||||
return ErrUnauthorized
|
||||
@@ -303,19 +414,26 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
||||
return ErrInvalidCard
|
||||
}
|
||||
|
||||
if room.Round.Revealed {
|
||||
if room.Settings.AutoReset {
|
||||
m.resetVotesLocked(room)
|
||||
} else {
|
||||
return ErrUnauthorized
|
||||
if participant.HasVoted {
|
||||
if participant.VoteValue == normalizedCard {
|
||||
return nil
|
||||
}
|
||||
if !room.Settings.AllowVoteChange {
|
||||
return ErrVoteChangeLocked
|
||||
}
|
||||
}
|
||||
|
||||
previousVote := participant.VoteValue
|
||||
hadVoted := participant.HasVoted
|
||||
participant.HasVoted = true
|
||||
participant.VoteValue = normalizedCard
|
||||
participant.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) {
|
||||
room.Round.Revealed = true
|
||||
@@ -330,7 +448,7 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) RevealVotes(roomID, participantID string) error {
|
||||
func (m *Manager) RevealVotes(roomID, participantID, sessionToken string) error {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -339,9 +457,9 @@ func (m *Manager) RevealVotes(roomID, participantID string) error {
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !participant.IsAdmin {
|
||||
return ErrUnauthorized
|
||||
@@ -359,7 +477,7 @@ func (m *Manager) RevealVotes(roomID, participantID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ResetVotes(roomID, participantID string) error {
|
||||
func (m *Manager) ResetVotes(roomID, participantID, sessionToken string) error {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -368,9 +486,9 @@ func (m *Manager) ResetVotes(roomID, participantID string) error {
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !participant.IsAdmin {
|
||||
return ErrUnauthorized
|
||||
@@ -388,17 +506,17 @@ func (m *Manager) ResetVotes(roomID, participantID string) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
participant, authErr := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||
if authErr != nil {
|
||||
room.mu.Unlock()
|
||||
return nil, nil, nil, ErrParticipantNotFound
|
||||
return nil, nil, nil, authErr
|
||||
}
|
||||
|
||||
participant.Connected = true
|
||||
@@ -466,6 +584,17 @@ func (m *Manager) getRoom(roomID string) (*Room, error) {
|
||||
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 {
|
||||
persistedRooms, err := m.store.LoadAll()
|
||||
if err != nil {
|
||||
@@ -473,20 +602,52 @@ func (m *Manager) loadFromDisk() error {
|
||||
}
|
||||
|
||||
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{
|
||||
ID: persisted.ID,
|
||||
AdminToken: persisted.AdminToken,
|
||||
CreatedAt: persisted.CreatedAt,
|
||||
UpdatedAt: persisted.UpdatedAt,
|
||||
Settings: persisted.Settings,
|
||||
Settings: settings,
|
||||
Round: persisted.Round,
|
||||
Participants: make(map[string]*Participant, len(persisted.Participants)),
|
||||
ActivityLog: append([]ActivityLogEntry(nil), persisted.ActivityLog...),
|
||||
subscribers: map[string]*subscriber{},
|
||||
}
|
||||
for _, participant := range persisted.Participants {
|
||||
participant.Connected = false
|
||||
room.Participants[participant.ID] = participant
|
||||
sessionToken := participant.SessionToken
|
||||
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
|
||||
@@ -496,10 +657,21 @@ func (m *Manager) loadFromDisk() error {
|
||||
}
|
||||
|
||||
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) {
|
||||
clone := *participant
|
||||
participants = append(participants, &clone)
|
||||
participants = append(participants, &persistedParticipant{
|
||||
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{
|
||||
@@ -507,7 +679,19 @@ func (room *Room) toPersisted() persistedRoom {
|
||||
AdminToken: room.AdminToken,
|
||||
CreatedAt: room.CreatedAt,
|
||||
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,
|
||||
Participants: participants,
|
||||
ActivityLog: append([]ActivityLogEntry(nil), room.ActivityLog...),
|
||||
@@ -583,6 +767,7 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
||||
AllowSpectators: room.Settings.AllowSpectators,
|
||||
AnonymousVoting: room.Settings.AnonymousVoting,
|
||||
AutoReset: room.Settings.AutoReset,
|
||||
AllowVoteChange: room.Settings.AllowVoteChange,
|
||||
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
||||
Participants: participants,
|
||||
SelfParticipantID: viewerParticipantID,
|
||||
@@ -596,8 +781,8 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
||||
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
||||
|
||||
start := 0
|
||||
if len(room.ActivityLog) > adminLogBroadcastLimit {
|
||||
start = len(room.ActivityLog) - adminLogBroadcastLimit
|
||||
if len(room.ActivityLog) > m.adminLogBroadcastLimit {
|
||||
start = len(room.ActivityLog) - m.adminLogBroadcastLimit
|
||||
}
|
||||
state.AdminLogs = make([]PublicActivityLogEntry, 0, len(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...),
|
||||
})
|
||||
|
||||
if len(room.ActivityLog) > maxActivityLogEntries {
|
||||
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-maxActivityLogEntries:]...)
|
||||
if len(room.ActivityLog) > m.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()
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
ErrPasswordRequired = errors.New("password required or invalid")
|
||||
ErrInvalidCard = errors.New("invalid card")
|
||||
ErrVoteChangeLocked = errors.New("vote changes are disabled for this room")
|
||||
)
|
||||
|
||||
type RoomSettings struct {
|
||||
@@ -32,22 +33,51 @@ type RoomSettings struct {
|
||||
AllowSpectators bool `json:"allowSpectators"`
|
||||
AnonymousVoting bool `json:"anonymousVoting"`
|
||||
AutoReset bool `json:"autoReset"`
|
||||
AllowVoteChange bool `json:"allowVoteChange"`
|
||||
RevealMode string `json:"revealMode"`
|
||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||
PasswordSalt string `json:"passwordSalt,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 {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Connected bool `json:"connected"`
|
||||
HasVoted bool `json:"hasVoted"`
|
||||
VoteValue string `json:"voteValue,omitempty"`
|
||||
JoinedAt time.Time `json:"joinedAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
SessionToken string `json:"-"`
|
||||
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 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 {
|
||||
@@ -64,9 +94,9 @@ type persistedRoom struct {
|
||||
AdminToken string `json:"adminToken"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Settings RoomSettings `json:"settings"`
|
||||
Settings persistedRoomSettings `json:"settings"`
|
||||
Round RoundState `json:"round"`
|
||||
Participants []*Participant `json:"participants"`
|
||||
Participants []*persistedParticipant `json:"participants"`
|
||||
ActivityLog []ActivityLogEntry `json:"activityLog,omitempty"`
|
||||
}
|
||||
|
||||
@@ -97,6 +127,7 @@ type CreateRoomInput struct {
|
||||
AllowSpectators bool
|
||||
AnonymousVoting bool
|
||||
AutoReset bool
|
||||
AllowVoteChange *bool
|
||||
RevealMode string
|
||||
VotingTimeoutSec int
|
||||
Password string
|
||||
@@ -104,6 +135,7 @@ type CreateRoomInput struct {
|
||||
|
||||
type JoinRoomInput struct {
|
||||
ParticipantID string
|
||||
SessionToken string
|
||||
Username string
|
||||
Role string
|
||||
Password string
|
||||
@@ -113,6 +145,7 @@ type JoinRoomInput struct {
|
||||
type CreateRoomResult struct {
|
||||
RoomID string `json:"roomId"`
|
||||
CreatorParticipantID string `json:"creatorParticipantId"`
|
||||
CreatorSessionToken string `json:"creatorSessionToken"`
|
||||
AdminToken string `json:"adminToken"`
|
||||
ParticipantLink string `json:"participantLink"`
|
||||
AdminLink string `json:"adminLink"`
|
||||
@@ -120,6 +153,7 @@ type CreateRoomResult struct {
|
||||
|
||||
type JoinRoomResult struct {
|
||||
ParticipantID string `json:"participantId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username"`
|
||||
@@ -155,6 +189,7 @@ type PublicRoomState struct {
|
||||
AllowSpectators bool `json:"allowSpectators"`
|
||||
AnonymousVoting bool `json:"anonymousVoting"`
|
||||
AutoReset bool `json:"autoReset"`
|
||||
AllowVoteChange bool `json:"allowVoteChange"`
|
||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||
Participants []PublicParticipant `json:"participants"`
|
||||
SelfParticipantID string `json:"selfParticipantId"`
|
||||
|
||||
@@ -62,6 +62,13 @@ func passwordMatches(password, salt, expectedHash string) bool {
|
||||
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 {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
@@ -86,7 +86,20 @@
|
||||
|
||||
<div class="field-group">
|
||||
<label for="room-password">Room password (optional)</label>
|
||||
<input type="password" id="room-password" name="password" maxlength="64" placeholder="Optional password">
|
||||
<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>
|
||||
|
||||
@@ -104,6 +117,10 @@
|
||||
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
|
||||
<span>Auto-reset cards after each reveal</span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -184,17 +201,17 @@
|
||||
aria-modal="false"
|
||||
aria-labelledby="theme-tool-title"
|
||||
data-ui-window
|
||||
data-window-title="ThemePicker.exe"
|
||||
data-window-title="Theme.exe"
|
||||
data-window-rights="all"
|
||||
data-window-order="10"
|
||||
data-window-default-left="16"
|
||||
data-window-default-top="88"
|
||||
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"}'
|
||||
>
|
||||
<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">
|
||||
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
||||
</div>
|
||||
@@ -215,32 +232,7 @@
|
||||
<span>No Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">Set the display mode.</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">
|
||||
<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">
|
||||
<div class="mobile-control-strip">
|
||||
<div class="taskbar-shell">
|
||||
<div class="taskbar-program-list">
|
||||
<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 class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +115,6 @@
|
||||
<div id="admin-controls" class="admin-controls hidden">
|
||||
<button type="button" id="reveal-btn" class="btn">Reveal</button>
|
||||
<button type="button" id="reset-btn" class="btn">Reset</button>
|
||||
<button type="button" id="terminal-btn" class="btn">Terminal</button>
|
||||
</div>
|
||||
<p id="room-message" class="status-line">Waiting for join...</p>
|
||||
</div>
|
||||
@@ -165,23 +155,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="terminal-modal-overlay" class="terminal-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="terminal-title">
|
||||
<section class="window terminal-window">
|
||||
<div class="title-bar">
|
||||
<span id="terminal-title">RoomTerminal.exe</span>
|
||||
<div class="title-bar-controls">
|
||||
<button type="button" id="terminal-close-btn">×</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>
|
||||
</div>
|
||||
|
||||
<section id="theme-tool-window" class="window ui-tool-window hidden" role="dialog" aria-modal="false" aria-labelledby="theme-tool-title">
|
||||
<section
|
||||
id="theme-tool-window"
|
||||
class="window ui-tool-window hidden"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="theme-tool-title"
|
||||
data-ui-window
|
||||
data-window-title="Theme.exe"
|
||||
data-window-rights="all"
|
||||
data-window-order="10"
|
||||
data-window-default-left="16"
|
||||
data-window-default-top="88"
|
||||
data-window-default-width="390"
|
||||
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"}'
|
||||
>
|
||||
<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">
|
||||
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
||||
</div>
|
||||
@@ -202,17 +193,7 @@
|
||||
<span>No Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">Set the display mode.</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">
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<footer class="taskbar desktop-taskbar" aria-label="Desktop taskbar">
|
||||
<div class="taskbar-shell">
|
||||
<div class="taskbar-program-list">
|
||||
<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 class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -362,6 +362,17 @@ body.is-dragging-window .ui-tool-title-bar {
|
||||
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 {
|
||||
min-height: 24rem;
|
||||
}
|
||||
@@ -421,12 +432,13 @@ body.is-dragging-window .ui-tool-title-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.participants-scroll {
|
||||
flex: 1;
|
||||
min-height: 13rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -499,25 +511,23 @@ body.is-dragging-window .ui-tool-title-bar {
|
||||
width: min(27rem, 92vw);
|
||||
}
|
||||
|
||||
.terminal-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 72;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal-window {
|
||||
width: min(46rem, 94vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-window-content {
|
||||
padding: 0.55rem;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-log-output {
|
||||
height: min(55vh, 30rem);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const USERNAME_KEY = 'scrumPoker.username';
|
||||
const PRESETS_KEY = 'scrumPoker.deckPresets.v1';
|
||||
const ROOM_SESSION_KEY_PREFIX = 'scrumPoker.roomSession.';
|
||||
|
||||
const SCALE_PRESETS = {
|
||||
fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'],
|
||||
@@ -502,6 +503,7 @@ roomConfigForm.addEventListener('submit', async (event) => {
|
||||
allowSpectators: Boolean(formData.get('allowSpectators')),
|
||||
anonymousVoting: Boolean(formData.get('anonymousVoting')),
|
||||
autoReset: Boolean(formData.get('autoReset')),
|
||||
allowVoteChange: Boolean(formData.get('allowVoteChange')),
|
||||
revealMode: (formData.get('revealMode') || 'manual').toString(),
|
||||
votingTimeoutSec: Number(formData.get('votingTimeoutSec') || 0),
|
||||
password: (formData.get('password') || '').toString(),
|
||||
@@ -522,7 +524,12 @@ roomConfigForm.addEventListener('submit', async (event) => {
|
||||
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);
|
||||
} catch (_err) {
|
||||
statusLine.textContent = 'Network error while creating room.';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const USERNAME_KEY = 'scrumPoker.username';
|
||||
const ROOM_SESSION_KEY_PREFIX = 'scrumPoker.roomSession.';
|
||||
|
||||
const roomID = document.body.dataset.roomId;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -16,15 +17,13 @@ const participantList = document.getElementById('participant-list');
|
||||
const adminControls = document.getElementById('admin-controls');
|
||||
const revealBtn = document.getElementById('reveal-btn');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
const terminalBtn = document.getElementById('terminal-btn');
|
||||
const shareLinkInput = document.getElementById('share-link');
|
||||
const shareAdminToggle = document.getElementById('share-admin-toggle');
|
||||
const votesCounter = document.getElementById('votes-counter');
|
||||
const roomMessage = document.getElementById('room-message');
|
||||
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 TERMINAL_WINDOW_ID = 'terminal-tool-window';
|
||||
|
||||
const joinPanel = document.getElementById('join-panel');
|
||||
const joinForm = document.getElementById('join-form');
|
||||
@@ -34,6 +33,7 @@ const joinPasswordInput = document.getElementById('join-password');
|
||||
const joinAdminTokenInput = document.getElementById('join-admin-token');
|
||||
const joinError = document.getElementById('join-error');
|
||||
let participantID = params.get('participantId') || '';
|
||||
let sessionToken = params.get('sessionToken') || '';
|
||||
let adminToken = params.get('adminToken') || '';
|
||||
const prefillUsername = params.get('username') || '';
|
||||
let eventSource = null;
|
||||
@@ -45,6 +45,45 @@ const savedUsername = localStorage.getItem(USERNAME_KEY) || '';
|
||||
joinUsernameInput.value = prefillUsername || savedUsername;
|
||||
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') {
|
||||
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() {
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.delete('username');
|
||||
|
||||
if (participantID) {
|
||||
next.searchParams.set('participantId', participantID);
|
||||
} else {
|
||||
next.searchParams.delete('participantId');
|
||||
}
|
||||
next.searchParams.delete('participantId');
|
||||
next.searchParams.delete('sessionToken');
|
||||
|
||||
if (adminToken) {
|
||||
next.searchParams.set('adminToken', adminToken);
|
||||
@@ -92,11 +127,15 @@ function setRoomMessage(message) {
|
||||
}
|
||||
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
participantId: participantIdOverride || participantID,
|
||||
participantId: rejoinParticipantID,
|
||||
sessionToken,
|
||||
username,
|
||||
role,
|
||||
password,
|
||||
@@ -110,7 +149,9 @@ async function joinRoom({ username, role, password, participantIdOverride }) {
|
||||
}
|
||||
|
||||
participantID = data.participantId;
|
||||
sessionToken = data.sessionToken;
|
||||
localStorage.setItem(USERNAME_KEY, data.username);
|
||||
persistRoomSession();
|
||||
updateURL();
|
||||
setJoinError('');
|
||||
return data;
|
||||
@@ -162,7 +203,9 @@ function parseNumericVote(value) {
|
||||
|
||||
function calculateSummary(state) {
|
||||
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) => {
|
||||
if (!participant.connected) {
|
||||
@@ -180,26 +223,42 @@ function calculateSummary(state) {
|
||||
|
||||
const numeric = parseNumericVote(participant.voteValue);
|
||||
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;
|
||||
if (numericVotes.length > 0) {
|
||||
average = numericVotes.reduce((acc, value) => acc + value, 0) / numericVotes.length;
|
||||
if (scoreVotes.length > 0) {
|
||||
average = scoreVotes.reduce((acc, value) => acc + value, 0) / scoreVotes.length;
|
||||
}
|
||||
|
||||
const deckNumeric = state.cards
|
||||
.map(parseNumericVote)
|
||||
.filter((value) => value !== null)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
let averageCard = null;
|
||||
let recommended = null;
|
||||
if (average !== null && deckNumeric.length > 0) {
|
||||
recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1];
|
||||
if (!hasNumericVote && average !== null && state.cards.length > 0) {
|
||||
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) {
|
||||
@@ -210,7 +269,7 @@ function renderSummary(state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows, average, recommended } = calculateSummary(state);
|
||||
const { rows, average, recommended, averageCard } = calculateSummary(state);
|
||||
summaryBody.innerHTML = '';
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function renderCards(cards, participants, isRevealed) {
|
||||
function renderCards(cards, participants, isRevealed, allowVoteChange) {
|
||||
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 : '';
|
||||
|
||||
votingBoard.innerHTML = '';
|
||||
@@ -315,22 +378,14 @@ function renderTerminalLogs(logs) {
|
||||
terminalLogOutput.scrollTop = terminalLogOutput.scrollHeight;
|
||||
}
|
||||
|
||||
function openTerminal() {
|
||||
terminalModalOverlay.classList.remove('hidden');
|
||||
renderTerminalLogs(latestAdminLogs);
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
terminalModalOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
roomTitle.textContent = `${state.roomName} (${state.roomId})`;
|
||||
revealModeLabel.textContent = `Reveal mode: ${state.revealMode}`;
|
||||
roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden';
|
||||
|
||||
renderParticipants(state.participants, state.revealed);
|
||||
renderCards(state.cards, state.participants, state.revealed);
|
||||
const allowVoteChange = state.allowVoteChange !== false;
|
||||
renderCards(state.cards, state.participants, state.revealed, allowVoteChange);
|
||||
renderSummary(state);
|
||||
|
||||
const self = state.participants.find((participant) => participant.id === participantID && participant.connected);
|
||||
@@ -341,16 +396,18 @@ function renderState(state) {
|
||||
latestLinks = state.links || { participantLink: '', adminLink: '' };
|
||||
updateShareLink();
|
||||
|
||||
if (typeof window.setUIWindowAccess === 'function') {
|
||||
window.setUIWindowAccess({ admin: state.viewerIsAdmin });
|
||||
}
|
||||
|
||||
if (state.viewerIsAdmin) {
|
||||
adminControls.classList.remove('hidden');
|
||||
terminalBtn.classList.remove('hidden');
|
||||
} else {
|
||||
adminControls.classList.add('hidden');
|
||||
terminalBtn.classList.add('hidden');
|
||||
closeTerminal();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -365,12 +422,40 @@ function updateShareLink() {
|
||||
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() {
|
||||
if (eventSource) {
|
||||
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) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
@@ -388,7 +473,7 @@ function connectSSE() {
|
||||
}
|
||||
|
||||
async function castVote(card) {
|
||||
if (!participantID) {
|
||||
if (!participantID || !sessionToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -396,7 +481,7 @@ async function castVote(card) {
|
||||
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ participantId: participantID, card }),
|
||||
body: JSON.stringify({ participantId: participantID, sessionToken, card }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -409,7 +494,7 @@ async function castVote(card) {
|
||||
}
|
||||
|
||||
async function adminAction(action) {
|
||||
if (!participantID) {
|
||||
if (!participantID || !sessionToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -417,7 +502,7 @@ async function adminAction(action) {
|
||||
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ participantId: participantID }),
|
||||
body: JSON.stringify({ participantId: participantID, sessionToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -430,7 +515,7 @@ async function adminAction(action) {
|
||||
}
|
||||
|
||||
async function changeName() {
|
||||
if (!participantID) {
|
||||
if (!participantID || !sessionToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -466,21 +551,24 @@ async function changeName() {
|
||||
|
||||
revealBtn.addEventListener('click', () => adminAction('reveal'));
|
||||
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);
|
||||
shareLinkInput.addEventListener('click', () => {
|
||||
void selectAndCopyShareLink();
|
||||
});
|
||||
changeNameBtn.addEventListener('click', () => {
|
||||
void changeName();
|
||||
});
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeTerminal();
|
||||
document.addEventListener('click', (event) => {
|
||||
const openBtn = event.target.closest('[data-role="open-window"]');
|
||||
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) => {
|
||||
@@ -495,21 +583,18 @@ joinForm.addEventListener('submit', async (event) => {
|
||||
adminToken = joinAdminTokenInput.value.trim();
|
||||
|
||||
try {
|
||||
const result = await joinRoom({
|
||||
await joinRoom({
|
||||
username,
|
||||
role: joinRoleInput.value,
|
||||
password: joinPasswordInput.value,
|
||||
participantIdOverride: participantID,
|
||||
});
|
||||
if (result.isAdmin) {
|
||||
const adminRoomURL = `/room/${encodeURIComponent(roomID)}?participantId=${encodeURIComponent(participantID)}&adminToken=${encodeURIComponent(adminToken)}`;
|
||||
window.location.assign(adminRoomURL);
|
||||
return;
|
||||
}
|
||||
connectSSE();
|
||||
} catch (err) {
|
||||
if (participantID) {
|
||||
if (participantID || sessionToken) {
|
||||
participantID = '';
|
||||
sessionToken = '';
|
||||
persistRoomSession();
|
||||
updateURL();
|
||||
}
|
||||
setJoinError(err.message);
|
||||
@@ -517,7 +602,7 @@ joinForm.addEventListener('submit', async (event) => {
|
||||
});
|
||||
|
||||
async function tryAutoJoinExistingParticipant() {
|
||||
if (!participantID) {
|
||||
if (!participantID || !sessionToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -533,16 +618,18 @@ async function tryAutoJoinExistingParticipant() {
|
||||
connectSSE();
|
||||
} catch (_err) {
|
||||
participantID = '';
|
||||
sessionToken = '';
|
||||
persistRoomSession();
|
||||
updateURL();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (!participantID) {
|
||||
if (!participantID || !sessionToken) {
|
||||
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' }));
|
||||
});
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
const DEFAULT_THEME = 'win98';
|
||||
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 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 windowLayouts = {};
|
||||
let windowDefs = [];
|
||||
let accessState = { admin: false };
|
||||
|
||||
function isMobileViewport() {
|
||||
return window.matchMedia('(max-width: 899px)').matches;
|
||||
@@ -29,58 +28,41 @@
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
function getCurrentTheme() {
|
||||
return document.documentElement.getAttribute('data-ui-theme') || DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function getCurrentMode() {
|
||||
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) {
|
||||
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() {
|
||||
try {
|
||||
const raw = localStorage.getItem(WINDOW_LAYOUTS_KEY);
|
||||
@@ -88,10 +70,7 @@
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return parsed;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (_err) {
|
||||
return {};
|
||||
}
|
||||
@@ -130,6 +109,7 @@
|
||||
const height = clamp(layout.height, minHeight, maxHeight);
|
||||
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));
|
||||
|
||||
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) {
|
||||
const id = windowEl.id;
|
||||
if (!id) {
|
||||
if (!id || isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
const next = clampLayoutToViewport(readLayoutFromDOM(windowEl));
|
||||
windowLayouts[id] = next;
|
||||
windowLayouts[id] = clampLayoutToViewport(readLayoutFromDOM(windowEl));
|
||||
saveWindowLayouts();
|
||||
}
|
||||
|
||||
function ensureWindowLayout(windowEl) {
|
||||
const id = windowEl.id;
|
||||
if (!id) {
|
||||
return;
|
||||
function measureLayoutFromContent(def) {
|
||||
const windowEl = def.el;
|
||||
const fallback = def.defaultLayout;
|
||||
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()) {
|
||||
windowEl.style.right = 'auto';
|
||||
windowEl.style.bottom = 'auto';
|
||||
windowEl.style.transform = 'none';
|
||||
def.el.style.right = 'auto';
|
||||
def.el.style.bottom = 'auto';
|
||||
def.el.style.transform = 'none';
|
||||
return;
|
||||
}
|
||||
const defaults = DEFAULT_WINDOW_LAYOUTS[id];
|
||||
const saved = windowLayouts[id];
|
||||
const normalized = normalizeLayout(saved, defaults);
|
||||
|
||||
const hasSavedLayout = hasOwn(options, 'hasSavedLayout')
|
||||
? options.hasSavedLayout
|
||||
: hasOwn(windowLayouts, def.id);
|
||||
const normalized = hasSavedLayout
|
||||
? normalizeLayout(windowLayouts[def.id], def.defaultLayout)
|
||||
: measureLayoutFromContent(def);
|
||||
const clamped = clampLayoutToViewport(normalized);
|
||||
applyLayout(windowEl, clamped);
|
||||
windowLayouts[id] = clamped;
|
||||
applyLayout(def.el, clamped);
|
||||
windowLayouts[def.id] = clamped;
|
||||
}
|
||||
|
||||
function openToolWindow(id) {
|
||||
const windowEl = document.getElementById(id);
|
||||
if (!windowEl) {
|
||||
function isWindowOpen(id) {
|
||||
const def = windowDefs.find((item) => item.id === id);
|
||||
return Boolean(def && !def.el.classList.contains('hidden'));
|
||||
}
|
||||
|
||||
function closeWindowById(id) {
|
||||
const def = windowDefs.find((item) => item.id === id);
|
||||
if (!def) {
|
||||
return;
|
||||
}
|
||||
ensureWindowLayout(windowEl);
|
||||
windowEl.classList.remove('hidden');
|
||||
bringWindowToFront(windowEl);
|
||||
def.el.classList.add('hidden');
|
||||
syncTaskButtons();
|
||||
}
|
||||
|
||||
function closeToolWindow(id) {
|
||||
const windowEl = document.getElementById(id);
|
||||
if (!windowEl) {
|
||||
function openWindowById(id) {
|
||||
const def = windowDefs.find((item) => item.id === id);
|
||||
if (!def || !hasWindowAccess(def)) {
|
||||
return;
|
||||
}
|
||||
windowEl.classList.add('hidden');
|
||||
ensureWindowLayout(def);
|
||||
def.el.classList.remove('hidden');
|
||||
bringWindowToFront(def.el);
|
||||
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() {
|
||||
let dragState = null;
|
||||
|
||||
document.querySelectorAll('[data-role="drag-handle"]').forEach((handle) => {
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
document.addEventListener('pointerdown', (event) => {
|
||||
const handle = event.target.closest('[data-role="drag-handle"]');
|
||||
if (!handle || event.button !== 0 || event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
if (isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowEl = handle.closest('.ui-tool-window');
|
||||
if (!windowEl || windowEl.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
if (isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
const windowEl = handle.closest('.ui-tool-window[data-ui-window]');
|
||||
if (!windowEl || windowEl.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
|
||||
bringWindowToFront(windowEl);
|
||||
const rect = windowEl.getBoundingClientRect();
|
||||
windowEl.style.left = `${rect.left}px`;
|
||||
windowEl.style.top = `${rect.top}px`;
|
||||
windowEl.style.right = 'auto';
|
||||
windowEl.style.bottom = 'auto';
|
||||
windowEl.style.transform = 'none';
|
||||
bringWindowToFront(windowEl);
|
||||
const rect = windowEl.getBoundingClientRect();
|
||||
windowEl.style.left = `${rect.left}px`;
|
||||
windowEl.style.top = `${rect.top}px`;
|
||||
windowEl.style.right = 'auto';
|
||||
windowEl.style.bottom = 'auto';
|
||||
windowEl.style.transform = 'none';
|
||||
|
||||
dragState = {
|
||||
pointerId: event.pointerId,
|
||||
windowEl,
|
||||
offsetX: event.clientX - rect.left,
|
||||
offsetY: event.clientY - rect.top,
|
||||
};
|
||||
dragState = {
|
||||
pointerId: event.pointerId,
|
||||
windowEl,
|
||||
offsetX: event.clientX - rect.left,
|
||||
offsetY: event.clientY - rect.top,
|
||||
};
|
||||
|
||||
document.body.classList.add('is-dragging-window');
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
document.body.classList.add('is-dragging-window');
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener('pointermove', (event) => {
|
||||
@@ -279,59 +447,64 @@
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const windowEl = entry.target;
|
||||
if (windowEl.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
if (isMobileViewport()) {
|
||||
if (windowEl.classList.contains('hidden') || isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
persistWindowLayout(windowEl);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
||||
observer.observe(windowEl);
|
||||
});
|
||||
windowDefs.forEach((def) => observer.observe(def.el));
|
||||
}
|
||||
|
||||
function initWindowLayouts() {
|
||||
windowLayouts = loadWindowLayouts();
|
||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
||||
ensureWindowLayout(windowEl);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
|
||||
const id = windowEl.id;
|
||||
if (!id) {
|
||||
function initTaskbarAndWindowEvents() {
|
||||
document.addEventListener('click', (event) => {
|
||||
const openBtn = event.target.closest('[data-role="open-window"]');
|
||||
if (openBtn) {
|
||||
const target = openBtn.dataset.target;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeLayout(windowLayouts[id], DEFAULT_WINDOW_LAYOUTS[id]);
|
||||
const clamped = clampLayoutToViewport(normalized);
|
||||
applyLayout(windowEl, clamped);
|
||||
windowLayouts[id] = clamped;
|
||||
if (isWindowOpen(target)) {
|
||||
closeWindowById(target);
|
||||
} else {
|
||||
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() {
|
||||
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();
|
||||
|
||||
function initThemeAndModeHandlers() {
|
||||
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const value = button.dataset.theme || DEFAULT_THEME;
|
||||
@@ -349,58 +522,34 @@
|
||||
syncControls();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-role="open-window"]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const target = button.dataset.target;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (isWindowOpen(target)) {
|
||||
closeToolWindow(target);
|
||||
return;
|
||||
}
|
||||
openToolWindow(target);
|
||||
});
|
||||
});
|
||||
function initUIControls() {
|
||||
if (window.__uiControlsInitialized) {
|
||||
syncControls();
|
||||
return;
|
||||
}
|
||||
window.__uiControlsInitialized = true;
|
||||
|
||||
document.querySelectorAll('[data-role="close-window"]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const target = button.dataset.target;
|
||||
if (!target) {
|
||||
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();
|
||||
});
|
||||
const savedTheme = localStorage.getItem(THEME_KEY) || DEFAULT_THEME;
|
||||
const savedMode = localStorage.getItem(MODE_KEY) || 'light';
|
||||
applyTheme(savedTheme);
|
||||
applyMode(savedMode);
|
||||
|
||||
collectWindowDefinitions();
|
||||
initWindowLayouts();
|
||||
initTaskbarAndWindowEvents();
|
||||
initThemeAndModeHandlers();
|
||||
initDraggableWindows();
|
||||
initResizableWindows();
|
||||
syncControls();
|
||||
}
|
||||
|
||||
window.initUIControls = initUIControls;
|
||||
window.isUIWindowOpen = isWindowOpen;
|
||||
window.openUIWindow = openWindowById;
|
||||
window.closeUIWindow = closeWindowById;
|
||||
window.setUIWindowAccess = setWindowAccess;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initUIControls, { once: true });
|
||||
|
||||
Reference in New Issue
Block a user