Compare commits

...

10 Commits

Author SHA1 Message Date
9e850d3b00 README.md env vars 2026-03-07 02:09:44 +02:00
95fde663ca ENV Update 2026-03-07 01:19:37 +02:00
98692359db Cleanup Job 2026-03-07 01:16:39 +02:00
e4e555cac3 Update 2026-03-07 01:12:36 +02:00
2b63370873 Updated password fields for autocomplete ignoring 2026-03-07 01:07:43 +02:00
791af99662 Updates 2026-03-07 00:52:47 +02:00
3ffe0d4958 HOST Variable 2026-03-06 17:16:56 +02:00
ec8e8911ce Security Updates 2026-03-06 12:30:05 +02:00
ffbaf0ee1d Terminal Fix 2026-03-06 11:17:23 +02:00
7299157ba9 Windows 2026-03-06 11:15:39 +02:00
16 changed files with 1098 additions and 412 deletions

View File

@@ -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
View File

@@ -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:

View File

@@ -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,
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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.",
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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()
}

View File

@@ -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="">

View File

@@ -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>

View File

@@ -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;

View File

@@ -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.';

View File

@@ -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' }));
});

View File

@@ -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 });