Compare commits
20 Commits
8f96d7514f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e850d3b00 | |||
| 95fde663ca | |||
| 98692359db | |||
| e4e555cac3 | |||
| 2b63370873 | |||
| 791af99662 | |||
| 3ffe0d4958 | |||
| ec8e8911ce | |||
| ffbaf0ee1d | |||
| 7299157ba9 | |||
| 5994e165c6 | |||
| f580775cc2 | |||
| f72875bef1 | |||
| 61417dbf92 | |||
| e879703041 | |||
| e520ff8bc5 | |||
| f9843744c7 | |||
| 637b5f0167 | |||
| 817bbfb44c | |||
| 7029eb29d4 |
@@ -23,7 +23,12 @@ COPY --from=build /app/static /app/static
|
|||||||
RUN mkdir -p /app/data && chown -R app:app /app
|
RUN mkdir -p /app/data && chown -R app:app /app
|
||||||
|
|
||||||
EXPOSE 8002
|
EXPOSE 8002
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8002
|
ENV PORT=8002
|
||||||
|
ENV MAX_ACTIVITY_LOG_ENTRIES=400
|
||||||
|
ENV ADMIN_LOG_BROADCAST_LIMIT=200
|
||||||
|
ENV STALE_ROOM_CLEANUP_INTERVAL=5m
|
||||||
|
ENV STALE_ROOM_TTL=30m
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
126
README.md
@@ -9,7 +9,11 @@ Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time ro
|
|||||||
- Real-time updates via Server-Sent Events (SSE)
|
- Real-time updates via Server-Sent Events (SSE)
|
||||||
- Strict state sanitization: unrevealed votes from other users are never broadcast
|
- Strict state sanitization: unrevealed votes from other users are never broadcast
|
||||||
- Backend authorization for admin-only actions (`reveal`, `reset`)
|
- Backend authorization for admin-only actions (`reveal`, `reset`)
|
||||||
- Win98-themed frontend with:
|
- Themeable frontend with:
|
||||||
|
- CSS architecture split into `main.css`, `layout.css`, and `/themes/*`
|
||||||
|
- Theme options: `win98` (default), `modern`, `No Theme`
|
||||||
|
- Light/Dark mode independent from active theme
|
||||||
|
- Desktop taskbar controls + mobile top controls for theme/mode switching
|
||||||
- Config page and card deck preview
|
- Config page and card deck preview
|
||||||
- Drag-and-drop card ordering
|
- Drag-and-drop card ordering
|
||||||
- Card add/remove with animation completion handling
|
- Card add/remove with animation completion handling
|
||||||
@@ -28,13 +32,108 @@ Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time ro
|
|||||||
- `src/middleware/`: static cache headers middleware
|
- `src/middleware/`: static cache headers middleware
|
||||||
- `src/models/`: template page data models
|
- `src/models/`: template page data models
|
||||||
- `src/templates/`: HTML templates (`index.html`, `room.html`)
|
- `src/templates/`: HTML templates (`index.html`, `room.html`)
|
||||||
- `static/css/`: styles
|
- `static/css/main.css`: shared component primitives
|
||||||
- `static/js/`: frontend logic (`config.js`, `room.js`)
|
- `static/css/layout.css`: grids/flex/positioning/responsive layout
|
||||||
|
- `static/css/themes/`: drop-in theme files
|
||||||
|
- `static/js/ui-controls.js`: global theme + mode engine
|
||||||
|
- `static/js/`: page logic (`config.js`, `room.js`)
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
- `PORT`: server port (default `8002`)
|
### Quick Reference
|
||||||
- `DATA_PATH`: directory for room JSON files (default `./data`)
|
|
||||||
|
- `HOST` (default: `0.0.0.0`): network interface/address the server binds to.
|
||||||
|
- `PORT` (default: `8002`): HTTP port the server listens on.
|
||||||
|
- `DATA_PATH` (default: `./data`): folder where room JSON files are stored.
|
||||||
|
- `MAX_ACTIVITY_LOG_ENTRIES` (default: `400`): max activity entries retained per room.
|
||||||
|
- `ADMIN_LOG_BROADCAST_LIMIT` (default: `200`): max recent log entries sent to admins over SSE.
|
||||||
|
- `STALE_ROOM_CLEANUP_INTERVAL` (default: `5m`): how often stale-room cleanup runs.
|
||||||
|
- `STALE_ROOM_TTL` (default: `30m`): empty rooms older than this are deleted.
|
||||||
|
|
||||||
|
### Variable Guide With Use Cases
|
||||||
|
|
||||||
|
`HOST`
|
||||||
|
- What it controls: where the web server is reachable from.
|
||||||
|
- Use case: local-only dev on one machine.
|
||||||
|
```bash
|
||||||
|
HOST=localhost PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: allow access from LAN/Docker/k8s ingress.
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`PORT`
|
||||||
|
- What it controls: server port.
|
||||||
|
- Use case: avoid conflicts with another app already on `8002`.
|
||||||
|
```bash
|
||||||
|
PORT=9000 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: common reverse-proxy port mapping.
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=8080 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATA_PATH`
|
||||||
|
- What it controls: disk location for persisted rooms.
|
||||||
|
- Use case: keep data in a dedicated local directory.
|
||||||
|
```bash
|
||||||
|
DATA_PATH=./tmp/rooms go run ./src
|
||||||
|
```
|
||||||
|
- Use case: persistent mount in containers.
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8002:8002 -e DATA_PATH=/app/data -v $(pwd)/data:/app/data scrum-solitare
|
||||||
|
```
|
||||||
|
|
||||||
|
`MAX_ACTIVITY_LOG_ENTRIES`
|
||||||
|
- What it controls: per-room in-memory/disk activity history cap.
|
||||||
|
- Use case: retain more history for auditing/debugging.
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=1000 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: reduce memory/storage in lightweight deployments.
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=100 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`ADMIN_LOG_BROADCAST_LIMIT`
|
||||||
|
- What it controls: maximum recent activity entries included in admin SSE payloads.
|
||||||
|
- Use case: richer admin timeline in active rooms.
|
||||||
|
```bash
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=400 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: smaller SSE payloads for lower bandwidth.
|
||||||
|
```bash
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=50 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`STALE_ROOM_CLEANUP_INTERVAL`
|
||||||
|
- What it controls: scheduler frequency for checking stale empty rooms.
|
||||||
|
- Use case: aggressive cleanup in ephemeral environments.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=1m go run ./src
|
||||||
|
```
|
||||||
|
- Use case: reduce cleanup churn in stable environments.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=15m go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`STALE_ROOM_TTL`
|
||||||
|
- What it controls: time since last room activity before an empty room is deleted.
|
||||||
|
- Use case: auto-prune quickly for temporary team rooms.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_TTL=10m go run ./src
|
||||||
|
```
|
||||||
|
- Use case: keep empty rooms around longer between sessions.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_TTL=2h go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
### Duration Format Notes
|
||||||
|
|
||||||
|
`STALE_ROOM_CLEANUP_INTERVAL` and `STALE_ROOM_TTL` use Go duration syntax:
|
||||||
|
- valid examples: `30s`, `5m`, `45m`, `2h`
|
||||||
|
- invalid examples: `5` (missing unit), `five minutes`
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
@@ -45,6 +144,23 @@ go run ./src
|
|||||||
|
|
||||||
Open `http://localhost:8002`.
|
Open `http://localhost:8002`.
|
||||||
|
|
||||||
|
Host binding examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOST=localhost PORT=8002 go run ./src
|
||||||
|
HOST=0.0.0.0 PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
State tuning example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=600 \
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=300 \
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=2m \
|
||||||
|
STALE_ROOM_TTL=45m \
|
||||||
|
go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|||||||
@@ -1,13 +1,55 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Host string
|
||||||
Port string
|
Port string
|
||||||
DataPath string
|
DataPath string
|
||||||
|
MaxActivityLogEntries int
|
||||||
|
AdminLogBroadcastLimit int
|
||||||
|
StaleRoomCleanupInterval time.Duration
|
||||||
|
StaleRoomTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func envIntOrDefault(name string, fallback int) int {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDurationOrDefault(name string, fallback time.Duration) time.Duration {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
|
host := os.Getenv("HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "0.0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8002"
|
port = "8002"
|
||||||
@@ -18,8 +60,18 @@ func Load() Config {
|
|||||||
dataPath = "./data"
|
dataPath = "./data"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxActivityLogEntries := envIntOrDefault("MAX_ACTIVITY_LOG_ENTRIES", 400)
|
||||||
|
adminLogBroadcastLimit := envIntOrDefault("ADMIN_LOG_BROADCAST_LIMIT", 200)
|
||||||
|
staleRoomCleanupInterval := envDurationOrDefault("STALE_ROOM_CLEANUP_INTERVAL", 5*time.Minute)
|
||||||
|
staleRoomTTL := envDurationOrDefault("STALE_ROOM_TTL", 30*time.Minute)
|
||||||
|
|
||||||
return Config{
|
return Config{
|
||||||
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
DataPath: dataPath,
|
DataPath: dataPath,
|
||||||
|
MaxActivityLogEntries: maxActivityLogEntries,
|
||||||
|
AdminLogBroadcastLimit: adminLogBroadcastLimit,
|
||||||
|
StaleRoomCleanupInterval: staleRoomCleanupInterval,
|
||||||
|
StaleRoomTTL: staleRoomTTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type createRoomRequest struct {
|
|||||||
AllowSpectators bool `json:"allowSpectators"`
|
AllowSpectators bool `json:"allowSpectators"`
|
||||||
AnonymousVoting bool `json:"anonymousVoting"`
|
AnonymousVoting bool `json:"anonymousVoting"`
|
||||||
AutoReset bool `json:"autoReset"`
|
AutoReset bool `json:"autoReset"`
|
||||||
|
AllowVoteChange *bool `json:"allowVoteChange"`
|
||||||
RevealMode string `json:"revealMode"`
|
RevealMode string `json:"revealMode"`
|
||||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -34,6 +35,7 @@ type createRoomRequest struct {
|
|||||||
|
|
||||||
type joinRoomRequest struct {
|
type joinRoomRequest struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -42,11 +44,13 @@ type joinRoomRequest struct {
|
|||||||
|
|
||||||
type voteRequest struct {
|
type voteRequest struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
Card string `json:"card"`
|
Card string `json:"card"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminActionRequest struct {
|
type adminActionRequest struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
||||||
@@ -64,6 +68,7 @@ func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
|
|||||||
AllowSpectators: req.AllowSpectators,
|
AllowSpectators: req.AllowSpectators,
|
||||||
AnonymousVoting: req.AnonymousVoting,
|
AnonymousVoting: req.AnonymousVoting,
|
||||||
AutoReset: req.AutoReset,
|
AutoReset: req.AutoReset,
|
||||||
|
AllowVoteChange: req.AllowVoteChange,
|
||||||
RevealMode: req.RevealMode,
|
RevealMode: req.RevealMode,
|
||||||
VotingTimeoutSec: req.VotingTimeoutSec,
|
VotingTimeoutSec: req.VotingTimeoutSec,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
@@ -85,6 +90,7 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) {
|
|||||||
|
|
||||||
result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{
|
result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{
|
||||||
ParticipantID: req.ParticipantID,
|
ParticipantID: req.ParticipantID,
|
||||||
|
SessionToken: req.SessionToken,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Role: req.Role,
|
Role: req.Role,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
@@ -101,12 +107,17 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) {
|
|||||||
func (h *RoomAPIHandler) StreamEvents(c *gin.Context) {
|
func (h *RoomAPIHandler) StreamEvents(c *gin.Context) {
|
||||||
roomID := c.Param("roomID")
|
roomID := c.Param("roomID")
|
||||||
participantID := c.Query("participantId")
|
participantID := c.Query("participantId")
|
||||||
|
sessionToken := c.Query("sessionToken")
|
||||||
if participantID == "" {
|
if participantID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if sessionToken == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sessionToken is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID)
|
stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID, sessionToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
@@ -169,7 +180,7 @@ func (h *RoomAPIHandler) CastVote(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.Card)
|
err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.SessionToken, req.Card)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
@@ -193,21 +204,21 @@ func (h *RoomAPIHandler) LeaveRoom(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID); err != nil {
|
if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID, req.SessionToken); err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string) error) {
|
func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string, string) error) {
|
||||||
var req adminActionRequest
|
var req adminActionRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fn(c.Param("roomID"), req.ParticipantID); err != nil {
|
if err := fn(c.Param("roomID"), req.ParticipantID, req.SessionToken); err != nil {
|
||||||
h.writeStateError(c, err)
|
h.writeStateError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -234,6 +245,9 @@ func (h *RoomAPIHandler) writeStateError(c *gin.Context, err error) {
|
|||||||
case errors.Is(err, state.ErrPasswordRequired):
|
case errors.Is(err, state.ErrPasswordRequired):
|
||||||
status = http.StatusUnauthorized
|
status = http.StatusUnauthorized
|
||||||
message = err.Error()
|
message = err.Error()
|
||||||
|
case errors.Is(err, state.ErrVoteChangeLocked):
|
||||||
|
status = http.StatusForbidden
|
||||||
|
message = err.Error()
|
||||||
case errors.Is(err, state.ErrSpectatorsBlocked), errors.Is(err, state.ErrInvalidCard), errors.Is(err, state.ErrInvalidRole):
|
case errors.Is(err, state.ErrSpectatorsBlocked), errors.Is(err, state.ErrInvalidCard), errors.Is(err, state.ErrInvalidRole):
|
||||||
status = http.StatusBadRequest
|
status = http.StatusBadRequest
|
||||||
message = err.Error()
|
message = err.Error()
|
||||||
|
|||||||
10
src/main.go
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
"scrum-solitare/src/config"
|
"scrum-solitare/src/config"
|
||||||
"scrum-solitare/src/handlers"
|
"scrum-solitare/src/handlers"
|
||||||
@@ -12,7 +13,12 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
manager, err := state.NewManager(cfg.DataPath)
|
manager, err := state.NewManager(cfg.DataPath, state.ManagerOptions{
|
||||||
|
MaxActivityLogEntries: cfg.MaxActivityLogEntries,
|
||||||
|
AdminLogBroadcastLimit: cfg.AdminLogBroadcastLimit,
|
||||||
|
StaleRoomCleanupInterval: cfg.StaleRoomCleanupInterval,
|
||||||
|
StaleRoomTTL: cfg.StaleRoomTTL,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize state manager: %v", err)
|
log.Fatalf("failed to initialize state manager: %v", err)
|
||||||
}
|
}
|
||||||
@@ -21,7 +27,7 @@ func main() {
|
|||||||
rooms := handlers.NewRoomAPIHandler(manager)
|
rooms := handlers.NewRoomAPIHandler(manager)
|
||||||
router := server.NewRouter(pages, rooms)
|
router := server.NewRouter(pages, rooms)
|
||||||
|
|
||||||
if err := router.Run(":" + cfg.Port); err != nil {
|
if err := router.Run(net.JoinHostPort(cfg.Host, cfg.Port)); err != nil {
|
||||||
log.Fatalf("server failed to start: %v", err)
|
log.Fatalf("server failed to start: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type RoomSetupPageData struct {
|
|||||||
AllowSpectators bool
|
AllowSpectators bool
|
||||||
AnonymousVoting bool
|
AnonymousVoting bool
|
||||||
AutoResetCards bool
|
AutoResetCards bool
|
||||||
|
AllowVoteChange bool
|
||||||
DefaultStatus string
|
DefaultStatus string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ func DefaultRoomSetupPageData() RoomSetupPageData {
|
|||||||
AllowSpectators: true,
|
AllowSpectators: true,
|
||||||
AnonymousVoting: true,
|
AnonymousVoting: true,
|
||||||
AutoResetCards: true,
|
AutoResetCards: true,
|
||||||
|
AllowVoteChange: true,
|
||||||
DefaultStatus: "Ready to create room.",
|
DefaultStatus: "Ready to create room.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,35 +2,139 @@ package state
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMaxActivityLogEntries = 400
|
||||||
|
defaultAdminLogBroadcastLimit = 200
|
||||||
|
defaultStaleRoomCleanupInterval = 5 * time.Minute
|
||||||
|
defaultStaleRoomTTL = 30 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]*Room
|
rooms map[string]*Room
|
||||||
store *DiskStore
|
store *DiskStore
|
||||||
|
|
||||||
|
maxActivityLogEntries int
|
||||||
|
adminLogBroadcastLimit int
|
||||||
|
staleRoomCleanupInterval time.Duration
|
||||||
|
staleRoomTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(dataPath string) (*Manager, error) {
|
type ManagerOptions struct {
|
||||||
|
MaxActivityLogEntries int
|
||||||
|
AdminLogBroadcastLimit int
|
||||||
|
StaleRoomCleanupInterval time.Duration
|
||||||
|
StaleRoomTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeManagerOptions(opts ManagerOptions) ManagerOptions {
|
||||||
|
if opts.MaxActivityLogEntries <= 0 {
|
||||||
|
opts.MaxActivityLogEntries = defaultMaxActivityLogEntries
|
||||||
|
}
|
||||||
|
if opts.AdminLogBroadcastLimit <= 0 {
|
||||||
|
opts.AdminLogBroadcastLimit = defaultAdminLogBroadcastLimit
|
||||||
|
}
|
||||||
|
if opts.StaleRoomCleanupInterval <= 0 {
|
||||||
|
opts.StaleRoomCleanupInterval = defaultStaleRoomCleanupInterval
|
||||||
|
}
|
||||||
|
if opts.StaleRoomTTL <= 0 {
|
||||||
|
opts.StaleRoomTTL = defaultStaleRoomTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(dataPath string, opts ManagerOptions) (*Manager, error) {
|
||||||
store, err := NewDiskStore(dataPath)
|
store, err := NewDiskStore(dataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
normalizedOpts := normalizeManagerOptions(opts)
|
||||||
|
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
rooms: make(map[string]*Room),
|
rooms: make(map[string]*Room),
|
||||||
store: store,
|
store: store,
|
||||||
|
maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries,
|
||||||
|
adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit,
|
||||||
|
staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval,
|
||||||
|
staleRoomTTL: normalizedOpts.StaleRoomTTL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
||||||
return nil, loadErr
|
return nil, loadErr
|
||||||
}
|
}
|
||||||
|
manager.startCleanupLoop()
|
||||||
|
|
||||||
return manager, nil
|
return manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) startCleanupLoop() {
|
||||||
|
ticker := time.NewTicker(m.staleRoomCleanupInterval)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
m.cleanupStaleRooms(nowUTC())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) cleanupStaleRooms(now time.Time) {
|
||||||
|
m.mu.RLock()
|
||||||
|
rooms := make([]*Room, 0, len(m.rooms))
|
||||||
|
for _, room := range m.rooms {
|
||||||
|
rooms = append(rooms, room)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, room := range rooms {
|
||||||
|
room.mu.Lock()
|
||||||
|
roomID := room.ID
|
||||||
|
hasConnected := hasConnectedParticipantsLocked(room)
|
||||||
|
recentlyActive := now.Sub(room.UpdatedAt) < m.staleRoomTTL
|
||||||
|
hasSubscribers := len(room.subscribers) > 0
|
||||||
|
room.mu.Unlock()
|
||||||
|
|
||||||
|
if hasConnected || recentlyActive || hasSubscribers {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
current, ok := m.rooms[roomID]
|
||||||
|
if !ok || current != room {
|
||||||
|
m.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.mu.Lock()
|
||||||
|
if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < m.staleRoomTTL || len(current.subscribers) > 0 {
|
||||||
|
current.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(m.rooms, roomID)
|
||||||
|
current.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err := m.store.Delete(roomID); err != nil {
|
||||||
|
log.Printf("failed to delete stale room %s: %v", roomID, err)
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.rooms[roomID]; !exists {
|
||||||
|
m.rooms[roomID] = room
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||||
roomName := normalizeName(input.RoomName, 80)
|
roomName := normalizeName(input.RoomName, 80)
|
||||||
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
||||||
@@ -70,6 +174,10 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
adminToken := randomHex(24)
|
adminToken := randomHex(24)
|
||||||
creatorID := newUUIDv4()
|
creatorID := newUUIDv4()
|
||||||
now := nowUTC()
|
now := nowUTC()
|
||||||
|
allowVoteChange := true
|
||||||
|
if input.AllowVoteChange != nil {
|
||||||
|
allowVoteChange = *input.AllowVoteChange
|
||||||
|
}
|
||||||
|
|
||||||
settings := RoomSettings{
|
settings := RoomSettings{
|
||||||
RoomName: roomName,
|
RoomName: roomName,
|
||||||
@@ -78,6 +186,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
AllowSpectators: input.AllowSpectators,
|
AllowSpectators: input.AllowSpectators,
|
||||||
AnonymousVoting: input.AnonymousVoting,
|
AnonymousVoting: input.AnonymousVoting,
|
||||||
AutoReset: input.AutoReset,
|
AutoReset: input.AutoReset,
|
||||||
|
AllowVoteChange: allowVoteChange,
|
||||||
RevealMode: revealMode,
|
RevealMode: revealMode,
|
||||||
VotingTimeoutSec: max(0, input.VotingTimeoutSec),
|
VotingTimeoutSec: max(0, input.VotingTimeoutSec),
|
||||||
}
|
}
|
||||||
@@ -89,7 +198,8 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
creator := &Participant{
|
creator := &Participant{
|
||||||
ID: creatorID,
|
ID: creatorID,
|
||||||
|
SessionToken: randomHex(24),
|
||||||
Username: creatorUsername,
|
Username: creatorUsername,
|
||||||
Role: RoleParticipant,
|
Role: RoleParticipant,
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
@@ -111,8 +221,10 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
Participants: map[string]*Participant{
|
Participants: map[string]*Participant{
|
||||||
creatorID: creator,
|
creatorID: creator,
|
||||||
},
|
},
|
||||||
|
ActivityLog: []ActivityLogEntry{},
|
||||||
subscribers: map[string]*subscriber{},
|
subscribers: map[string]*subscriber{},
|
||||||
}
|
}
|
||||||
|
m.appendActivityLogLocked(room, "%s created the room as admin.", creator.Username)
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.rooms[roomID] = room
|
m.rooms[roomID] = room
|
||||||
@@ -131,6 +243,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
|||||||
result := CreateRoomResult{
|
result := CreateRoomResult{
|
||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
CreatorParticipantID: creatorID,
|
CreatorParticipantID: creatorID,
|
||||||
|
CreatorSessionToken: creator.SessionToken,
|
||||||
AdminToken: adminToken,
|
AdminToken: adminToken,
|
||||||
ParticipantLink: "/room/" + roomID,
|
ParticipantLink: "/room/" + roomID,
|
||||||
AdminLink: "/room/" + roomID + "?adminToken=" + adminToken,
|
AdminLink: "/room/" + roomID + "?adminToken=" + adminToken,
|
||||||
@@ -175,13 +288,20 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
if !ok {
|
if !ok {
|
||||||
return JoinRoomResult{}, ErrParticipantNotFound
|
return JoinRoomResult{}, ErrParticipantNotFound
|
||||||
}
|
}
|
||||||
|
if !secureTokenMatches(existing.SessionToken, input.SessionToken) {
|
||||||
|
return JoinRoomResult{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
wasConnected := existing.Connected
|
||||||
existing.Username = username
|
existing.Username = username
|
||||||
existing.Connected = true
|
existing.Connected = true
|
||||||
existing.UpdatedAt = now
|
existing.UpdatedAt = now
|
||||||
if isAdminByToken {
|
if isAdminByToken {
|
||||||
existing.IsAdmin = true
|
existing.IsAdmin = true
|
||||||
}
|
}
|
||||||
|
if !wasConnected {
|
||||||
|
m.appendActivityLogLocked(room, "%s joined as %s.", existing.Username, existing.Role)
|
||||||
|
}
|
||||||
|
|
||||||
room.UpdatedAt = now
|
room.UpdatedAt = now
|
||||||
if err := m.store.Save(room); err != nil {
|
if err := m.store.Save(room); err != nil {
|
||||||
@@ -191,6 +311,7 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
go m.broadcastRoom(room.ID)
|
go m.broadcastRoom(room.ID)
|
||||||
return JoinRoomResult{
|
return JoinRoomResult{
|
||||||
ParticipantID: existing.ID,
|
ParticipantID: existing.ID,
|
||||||
|
SessionToken: existing.SessionToken,
|
||||||
IsAdmin: existing.IsAdmin,
|
IsAdmin: existing.IsAdmin,
|
||||||
Role: existing.Role,
|
Role: existing.Role,
|
||||||
Username: existing.Username,
|
Username: existing.Username,
|
||||||
@@ -214,7 +335,8 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
}
|
}
|
||||||
|
|
||||||
participant := &Participant{
|
participant := &Participant{
|
||||||
ID: newUUIDv4(),
|
ID: newUUIDv4(),
|
||||||
|
SessionToken: randomHex(24),
|
||||||
Username: username,
|
Username: username,
|
||||||
Role: role,
|
Role: role,
|
||||||
IsAdmin: isAdminByToken,
|
IsAdmin: isAdminByToken,
|
||||||
@@ -225,6 +347,7 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
}
|
}
|
||||||
|
|
||||||
room.Participants[participant.ID] = participant
|
room.Participants[participant.ID] = participant
|
||||||
|
m.appendActivityLogLocked(room, "%s joined as %s.", participant.Username, participant.Role)
|
||||||
room.UpdatedAt = now
|
room.UpdatedAt = now
|
||||||
|
|
||||||
if err := m.store.Save(room); err != nil {
|
if err := m.store.Save(room); err != nil {
|
||||||
@@ -234,13 +357,14 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult,
|
|||||||
go m.broadcastRoom(room.ID)
|
go m.broadcastRoom(room.ID)
|
||||||
return JoinRoomResult{
|
return JoinRoomResult{
|
||||||
ParticipantID: participant.ID,
|
ParticipantID: participant.ID,
|
||||||
|
SessionToken: participant.SessionToken,
|
||||||
IsAdmin: participant.IsAdmin,
|
IsAdmin: participant.IsAdmin,
|
||||||
Role: participant.Role,
|
Role: participant.Role,
|
||||||
Username: participant.Username,
|
Username: participant.Username,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
func (m *Manager) LeaveRoom(roomID, participantID, sessionToken string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -249,14 +373,16 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
participant.Connected = false
|
if !participant.Connected {
|
||||||
participant.UpdatedAt = nowUTC()
|
return nil
|
||||||
room.UpdatedAt = nowUTC()
|
}
|
||||||
|
m.disconnectParticipantLocked(room, participant)
|
||||||
|
m.appendActivityLogLocked(room, "%s left the room.", participant.Username)
|
||||||
|
|
||||||
if err := m.store.Save(room); err != nil {
|
if err := m.store.Save(room); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -266,7 +392,7 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) CastVote(roomID, participantID, card string) error {
|
func (m *Manager) CastVote(roomID, participantID, sessionToken, card string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -275,9 +401,9 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
if participant.Role != RoleParticipant {
|
if participant.Role != RoleParticipant {
|
||||||
return ErrUnauthorized
|
return ErrUnauthorized
|
||||||
@@ -288,21 +414,30 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
|||||||
return ErrInvalidCard
|
return ErrInvalidCard
|
||||||
}
|
}
|
||||||
|
|
||||||
if room.Round.Revealed {
|
if participant.HasVoted {
|
||||||
if room.Settings.AutoReset {
|
if participant.VoteValue == normalizedCard {
|
||||||
m.resetVotesLocked(room)
|
return nil
|
||||||
} else {
|
}
|
||||||
return ErrUnauthorized
|
if !room.Settings.AllowVoteChange {
|
||||||
|
return ErrVoteChangeLocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousVote := participant.VoteValue
|
||||||
|
hadVoted := participant.HasVoted
|
||||||
participant.HasVoted = true
|
participant.HasVoted = true
|
||||||
participant.VoteValue = normalizedCard
|
participant.VoteValue = normalizedCard
|
||||||
participant.UpdatedAt = nowUTC()
|
participant.UpdatedAt = nowUTC()
|
||||||
room.UpdatedAt = nowUTC()
|
room.UpdatedAt = nowUTC()
|
||||||
|
if hadVoted {
|
||||||
|
m.appendActivityLogLocked(room, "%s changed vote from %s to %s.", participant.Username, previousVote, normalizedCard)
|
||||||
|
} else {
|
||||||
|
m.appendActivityLogLocked(room, "%s voted %s.", participant.Username, normalizedCard)
|
||||||
|
}
|
||||||
|
|
||||||
if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) {
|
if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) {
|
||||||
room.Round.Revealed = true
|
room.Round.Revealed = true
|
||||||
|
m.appendActivityLogLocked(room, "Votes auto-revealed after all active participants voted.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.store.Save(room); err != nil {
|
if err := m.store.Save(room); err != nil {
|
||||||
@@ -313,7 +448,7 @@ func (m *Manager) CastVote(roomID, participantID, card string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RevealVotes(roomID, participantID string) error {
|
func (m *Manager) RevealVotes(roomID, participantID, sessionToken string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -322,9 +457,9 @@ func (m *Manager) RevealVotes(roomID, participantID string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
if !participant.IsAdmin {
|
if !participant.IsAdmin {
|
||||||
return ErrUnauthorized
|
return ErrUnauthorized
|
||||||
@@ -332,6 +467,7 @@ func (m *Manager) RevealVotes(roomID, participantID string) error {
|
|||||||
|
|
||||||
room.Round.Revealed = true
|
room.Round.Revealed = true
|
||||||
room.UpdatedAt = nowUTC()
|
room.UpdatedAt = nowUTC()
|
||||||
|
m.appendActivityLogLocked(room, "%s revealed the votes.", participant.Username)
|
||||||
|
|
||||||
if err := m.store.Save(room); err != nil {
|
if err := m.store.Save(room); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -341,7 +477,7 @@ func (m *Manager) RevealVotes(roomID, participantID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ResetVotes(roomID, participantID string) error {
|
func (m *Manager) ResetVotes(roomID, participantID, sessionToken string) error {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -350,9 +486,9 @@ func (m *Manager) ResetVotes(roomID, participantID string) error {
|
|||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
defer room.mu.Unlock()
|
defer room.mu.Unlock()
|
||||||
|
|
||||||
participant, ok := room.Participants[participantID]
|
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrParticipantNotFound
|
return err
|
||||||
}
|
}
|
||||||
if !participant.IsAdmin {
|
if !participant.IsAdmin {
|
||||||
return ErrUnauthorized
|
return ErrUnauthorized
|
||||||
@@ -360,6 +496,7 @@ func (m *Manager) ResetVotes(roomID, participantID string) error {
|
|||||||
|
|
||||||
m.resetVotesLocked(room)
|
m.resetVotesLocked(room)
|
||||||
room.UpdatedAt = nowUTC()
|
room.UpdatedAt = nowUTC()
|
||||||
|
m.appendActivityLogLocked(room, "%s reset all votes.", participant.Username)
|
||||||
|
|
||||||
if err := m.store.Save(room); err != nil {
|
if err := m.store.Save(room); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -369,17 +506,17 @@ func (m *Manager) ResetVotes(roomID, participantID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Subscribe(roomID, participantID string) (<-chan []byte, []byte, func(), error) {
|
func (m *Manager) Subscribe(roomID, participantID, sessionToken string) (<-chan []byte, []byte, func(), error) {
|
||||||
room, err := m.getRoom(roomID)
|
room, err := m.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
room.mu.Lock()
|
room.mu.Lock()
|
||||||
participant, ok := room.Participants[participantID]
|
participant, authErr := m.authorizeParticipantLocked(room, participantID, sessionToken)
|
||||||
if !ok {
|
if authErr != nil {
|
||||||
room.mu.Unlock()
|
room.mu.Unlock()
|
||||||
return nil, nil, nil, ErrParticipantNotFound
|
return nil, nil, nil, authErr
|
||||||
}
|
}
|
||||||
|
|
||||||
participant.Connected = true
|
participant.Connected = true
|
||||||
@@ -421,10 +558,11 @@ func (m *Manager) Subscribe(roomID, participantID string) (<-chan []byte, []byte
|
|||||||
delete(roomRef.subscribers, subscriberID)
|
delete(roomRef.subscribers, subscriberID)
|
||||||
|
|
||||||
if p, participantOK := roomRef.Participants[participantID]; participantOK {
|
if p, participantOK := roomRef.Participants[participantID]; participantOK {
|
||||||
p.Connected = false
|
if p.Connected {
|
||||||
p.UpdatedAt = nowUTC()
|
m.disconnectParticipantLocked(roomRef, p)
|
||||||
roomRef.UpdatedAt = nowUTC()
|
m.appendActivityLogLocked(roomRef, "%s disconnected.", p.Username)
|
||||||
_ = m.store.Save(roomRef)
|
_ = m.store.Save(roomRef)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
roomRef.mu.Unlock()
|
roomRef.mu.Unlock()
|
||||||
|
|
||||||
@@ -446,6 +584,17 @@ func (m *Manager) getRoom(roomID string) (*Room, error) {
|
|||||||
return room, nil
|
return room, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) authorizeParticipantLocked(room *Room, participantID, sessionToken string) (*Participant, error) {
|
||||||
|
participant, ok := room.Participants[participantID]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrParticipantNotFound
|
||||||
|
}
|
||||||
|
if !secureTokenMatches(participant.SessionToken, sessionToken) {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
return participant, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) loadFromDisk() error {
|
func (m *Manager) loadFromDisk() error {
|
||||||
persistedRooms, err := m.store.LoadAll()
|
persistedRooms, err := m.store.LoadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -453,19 +602,52 @@ func (m *Manager) loadFromDisk() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, persisted := range persistedRooms {
|
for _, persisted := range persistedRooms {
|
||||||
|
allowVoteChange := true
|
||||||
|
if persisted.Settings.AllowVoteChange != nil {
|
||||||
|
allowVoteChange = *persisted.Settings.AllowVoteChange
|
||||||
|
}
|
||||||
|
settings := RoomSettings{
|
||||||
|
RoomName: persisted.Settings.RoomName,
|
||||||
|
MaxPeople: persisted.Settings.MaxPeople,
|
||||||
|
Cards: append([]string(nil), persisted.Settings.Cards...),
|
||||||
|
AllowSpectators: persisted.Settings.AllowSpectators,
|
||||||
|
AnonymousVoting: persisted.Settings.AnonymousVoting,
|
||||||
|
AutoReset: persisted.Settings.AutoReset,
|
||||||
|
AllowVoteChange: allowVoteChange,
|
||||||
|
RevealMode: persisted.Settings.RevealMode,
|
||||||
|
VotingTimeoutSec: persisted.Settings.VotingTimeoutSec,
|
||||||
|
PasswordSalt: persisted.Settings.PasswordSalt,
|
||||||
|
PasswordHash: persisted.Settings.PasswordHash,
|
||||||
|
}
|
||||||
|
|
||||||
room := &Room{
|
room := &Room{
|
||||||
ID: persisted.ID,
|
ID: persisted.ID,
|
||||||
AdminToken: persisted.AdminToken,
|
AdminToken: persisted.AdminToken,
|
||||||
CreatedAt: persisted.CreatedAt,
|
CreatedAt: persisted.CreatedAt,
|
||||||
UpdatedAt: persisted.UpdatedAt,
|
UpdatedAt: persisted.UpdatedAt,
|
||||||
Settings: persisted.Settings,
|
Settings: settings,
|
||||||
Round: persisted.Round,
|
Round: persisted.Round,
|
||||||
Participants: make(map[string]*Participant, len(persisted.Participants)),
|
Participants: make(map[string]*Participant, len(persisted.Participants)),
|
||||||
|
ActivityLog: append([]ActivityLogEntry(nil), persisted.ActivityLog...),
|
||||||
subscribers: map[string]*subscriber{},
|
subscribers: map[string]*subscriber{},
|
||||||
}
|
}
|
||||||
for _, participant := range persisted.Participants {
|
for _, participant := range persisted.Participants {
|
||||||
participant.Connected = false
|
sessionToken := participant.SessionToken
|
||||||
room.Participants[participant.ID] = participant
|
if sessionToken == "" {
|
||||||
|
sessionToken = randomHex(24)
|
||||||
|
}
|
||||||
|
room.Participants[participant.ID] = &Participant{
|
||||||
|
ID: participant.ID,
|
||||||
|
SessionToken: sessionToken,
|
||||||
|
Username: participant.Username,
|
||||||
|
Role: participant.Role,
|
||||||
|
IsAdmin: participant.IsAdmin,
|
||||||
|
Connected: false,
|
||||||
|
HasVoted: participant.HasVoted,
|
||||||
|
VoteValue: participant.VoteValue,
|
||||||
|
JoinedAt: participant.JoinedAt,
|
||||||
|
UpdatedAt: participant.UpdatedAt,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.rooms[room.ID] = room
|
m.rooms[room.ID] = room
|
||||||
@@ -475,10 +657,21 @@ func (m *Manager) loadFromDisk() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (room *Room) toPersisted() persistedRoom {
|
func (room *Room) toPersisted() persistedRoom {
|
||||||
participants := make([]*Participant, 0, len(room.Participants))
|
allowVoteChange := room.Settings.AllowVoteChange
|
||||||
|
participants := make([]*persistedParticipant, 0, len(room.Participants))
|
||||||
for _, participant := range sortParticipants(room.Participants) {
|
for _, participant := range sortParticipants(room.Participants) {
|
||||||
clone := *participant
|
participants = append(participants, &persistedParticipant{
|
||||||
participants = append(participants, &clone)
|
ID: participant.ID,
|
||||||
|
SessionToken: participant.SessionToken,
|
||||||
|
Username: participant.Username,
|
||||||
|
Role: participant.Role,
|
||||||
|
IsAdmin: participant.IsAdmin,
|
||||||
|
Connected: participant.Connected,
|
||||||
|
HasVoted: participant.HasVoted,
|
||||||
|
VoteValue: participant.VoteValue,
|
||||||
|
JoinedAt: participant.JoinedAt,
|
||||||
|
UpdatedAt: participant.UpdatedAt,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedRoom{
|
return persistedRoom{
|
||||||
@@ -486,9 +679,22 @@ func (room *Room) toPersisted() persistedRoom {
|
|||||||
AdminToken: room.AdminToken,
|
AdminToken: room.AdminToken,
|
||||||
CreatedAt: room.CreatedAt,
|
CreatedAt: room.CreatedAt,
|
||||||
UpdatedAt: room.UpdatedAt,
|
UpdatedAt: room.UpdatedAt,
|
||||||
Settings: room.Settings,
|
Settings: persistedRoomSettings{
|
||||||
|
RoomName: room.Settings.RoomName,
|
||||||
|
MaxPeople: room.Settings.MaxPeople,
|
||||||
|
Cards: append([]string(nil), room.Settings.Cards...),
|
||||||
|
AllowSpectators: room.Settings.AllowSpectators,
|
||||||
|
AnonymousVoting: room.Settings.AnonymousVoting,
|
||||||
|
AutoReset: room.Settings.AutoReset,
|
||||||
|
AllowVoteChange: &allowVoteChange,
|
||||||
|
RevealMode: room.Settings.RevealMode,
|
||||||
|
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
||||||
|
PasswordSalt: room.Settings.PasswordSalt,
|
||||||
|
PasswordHash: room.Settings.PasswordHash,
|
||||||
|
},
|
||||||
Round: room.Round,
|
Round: room.Round,
|
||||||
Participants: participants,
|
Participants: participants,
|
||||||
|
ActivityLog: append([]ActivityLogEntry(nil), room.ActivityLog...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +735,10 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
|||||||
|
|
||||||
participants := make([]PublicParticipant, 0, len(room.Participants))
|
participants := make([]PublicParticipant, 0, len(room.Participants))
|
||||||
for _, participant := range sortParticipants(room.Participants) {
|
for _, participant := range sortParticipants(room.Participants) {
|
||||||
|
if !participant.Connected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
public := PublicParticipant{
|
public := PublicParticipant{
|
||||||
ID: participant.ID,
|
ID: participant.ID,
|
||||||
Username: participant.Username,
|
Username: participant.Username,
|
||||||
@@ -557,6 +767,7 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
|||||||
AllowSpectators: room.Settings.AllowSpectators,
|
AllowSpectators: room.Settings.AllowSpectators,
|
||||||
AnonymousVoting: room.Settings.AnonymousVoting,
|
AnonymousVoting: room.Settings.AnonymousVoting,
|
||||||
AutoReset: room.Settings.AutoReset,
|
AutoReset: room.Settings.AutoReset,
|
||||||
|
AllowVoteChange: room.Settings.AllowVoteChange,
|
||||||
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
||||||
Participants: participants,
|
Participants: participants,
|
||||||
SelfParticipantID: viewerParticipantID,
|
SelfParticipantID: viewerParticipantID,
|
||||||
@@ -568,6 +779,18 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
|||||||
|
|
||||||
if viewer.IsAdmin {
|
if viewer.IsAdmin {
|
||||||
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
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:] {
|
||||||
|
state.AdminLogs = append(state.AdminLogs, PublicActivityLogEntry{
|
||||||
|
At: item.At,
|
||||||
|
Message: item.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(state)
|
return json.Marshal(state)
|
||||||
@@ -602,3 +825,30 @@ func (m *Manager) broadcastRoom(roomID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) appendActivityLogLocked(room *Room, format string, args ...any) {
|
||||||
|
room.ActivityLog = append(room.ActivityLog, ActivityLogEntry{
|
||||||
|
At: nowUTC(),
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(room.ActivityLog) > m.maxActivityLogEntries {
|
||||||
|
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-m.maxActivityLogEntries:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participant) {
|
||||||
|
participant.Connected = false
|
||||||
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrRoomNotFound = errors.New("room not found")
|
ErrRoomNotFound = errors.New("room not found")
|
||||||
ErrParticipantNotFound = errors.New("participant not found")
|
ErrParticipantNotFound = errors.New("participant not found")
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrRoomFull = errors.New("room is full")
|
ErrRoomFull = errors.New("room is full")
|
||||||
ErrInvalidRole = errors.New("invalid role")
|
ErrInvalidRole = errors.New("invalid role")
|
||||||
ErrSpectatorsBlocked = errors.New("spectators are not allowed")
|
ErrSpectatorsBlocked = errors.New("spectators are not allowed")
|
||||||
ErrPasswordRequired = errors.New("password required or invalid")
|
ErrPasswordRequired = errors.New("password required or invalid")
|
||||||
ErrInvalidCard = errors.New("invalid card")
|
ErrInvalidCard = errors.New("invalid card")
|
||||||
|
ErrVoteChangeLocked = errors.New("vote changes are disabled for this room")
|
||||||
)
|
)
|
||||||
|
|
||||||
type RoomSettings struct {
|
type RoomSettings struct {
|
||||||
@@ -32,36 +33,71 @@ type RoomSettings struct {
|
|||||||
AllowSpectators bool `json:"allowSpectators"`
|
AllowSpectators bool `json:"allowSpectators"`
|
||||||
AnonymousVoting bool `json:"anonymousVoting"`
|
AnonymousVoting bool `json:"anonymousVoting"`
|
||||||
AutoReset bool `json:"autoReset"`
|
AutoReset bool `json:"autoReset"`
|
||||||
|
AllowVoteChange bool `json:"allowVoteChange"`
|
||||||
RevealMode string `json:"revealMode"`
|
RevealMode string `json:"revealMode"`
|
||||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||||
PasswordHash string `json:"passwordHash,omitempty"`
|
PasswordHash string `json:"passwordHash,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type persistedRoomSettings struct {
|
||||||
|
RoomName string `json:"roomName"`
|
||||||
|
MaxPeople int `json:"maxPeople"`
|
||||||
|
Cards []string `json:"cards"`
|
||||||
|
AllowSpectators bool `json:"allowSpectators"`
|
||||||
|
AnonymousVoting bool `json:"anonymousVoting"`
|
||||||
|
AutoReset bool `json:"autoReset"`
|
||||||
|
AllowVoteChange *bool `json:"allowVoteChange,omitempty"`
|
||||||
|
RevealMode string `json:"revealMode"`
|
||||||
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||||
|
PasswordHash string `json:"passwordHash,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Participant struct {
|
type Participant struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
SessionToken string `json:"-"`
|
||||||
Role string `json:"role"`
|
Username string `json:"username"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
Role string `json:"role"`
|
||||||
Connected bool `json:"connected"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
HasVoted bool `json:"hasVoted"`
|
Connected bool `json:"connected"`
|
||||||
VoteValue string `json:"voteValue,omitempty"`
|
HasVoted bool `json:"hasVoted"`
|
||||||
JoinedAt time.Time `json:"joinedAt"`
|
VoteValue string `json:"voteValue,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
JoinedAt time.Time `json:"joinedAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type persistedParticipant struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionToken string `json:"sessionToken,omitempty"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
IsAdmin bool `json:"isAdmin"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
HasVoted bool `json:"hasVoted"`
|
||||||
|
VoteValue string `json:"voteValue,omitempty"`
|
||||||
|
JoinedAt time.Time `json:"joinedAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoundState struct {
|
type RoundState struct {
|
||||||
Revealed bool `json:"revealed"`
|
Revealed bool `json:"revealed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActivityLogEntry struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
type persistedRoom struct {
|
type persistedRoom struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
AdminToken string `json:"adminToken"`
|
AdminToken string `json:"adminToken"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
Settings RoomSettings `json:"settings"`
|
Settings persistedRoomSettings `json:"settings"`
|
||||||
Round RoundState `json:"round"`
|
Round RoundState `json:"round"`
|
||||||
Participants []*Participant `json:"participants"`
|
Participants []*persistedParticipant `json:"participants"`
|
||||||
|
ActivityLog []ActivityLogEntry `json:"activityLog,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type subscriber struct {
|
type subscriber struct {
|
||||||
@@ -77,6 +113,7 @@ type Room struct {
|
|||||||
Settings RoomSettings
|
Settings RoomSettings
|
||||||
Round RoundState
|
Round RoundState
|
||||||
Participants map[string]*Participant
|
Participants map[string]*Participant
|
||||||
|
ActivityLog []ActivityLogEntry
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
subscribers map[string]*subscriber
|
subscribers map[string]*subscriber
|
||||||
@@ -90,6 +127,7 @@ type CreateRoomInput struct {
|
|||||||
AllowSpectators bool
|
AllowSpectators bool
|
||||||
AnonymousVoting bool
|
AnonymousVoting bool
|
||||||
AutoReset bool
|
AutoReset bool
|
||||||
|
AllowVoteChange *bool
|
||||||
RevealMode string
|
RevealMode string
|
||||||
VotingTimeoutSec int
|
VotingTimeoutSec int
|
||||||
Password string
|
Password string
|
||||||
@@ -97,6 +135,7 @@ type CreateRoomInput struct {
|
|||||||
|
|
||||||
type JoinRoomInput struct {
|
type JoinRoomInput struct {
|
||||||
ParticipantID string
|
ParticipantID string
|
||||||
|
SessionToken string
|
||||||
Username string
|
Username string
|
||||||
Role string
|
Role string
|
||||||
Password string
|
Password string
|
||||||
@@ -104,15 +143,17 @@ type JoinRoomInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateRoomResult struct {
|
type CreateRoomResult struct {
|
||||||
RoomID string `json:"roomId"`
|
RoomID string `json:"roomId"`
|
||||||
CreatorParticipantID string `json:"creatorParticipantId"`
|
CreatorParticipantID string `json:"creatorParticipantId"`
|
||||||
AdminToken string `json:"adminToken"`
|
CreatorSessionToken string `json:"creatorSessionToken"`
|
||||||
ParticipantLink string `json:"participantLink"`
|
AdminToken string `json:"adminToken"`
|
||||||
AdminLink string `json:"adminLink"`
|
ParticipantLink string `json:"participantLink"`
|
||||||
|
AdminLink string `json:"adminLink"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JoinRoomResult struct {
|
type JoinRoomResult struct {
|
||||||
ParticipantID string `json:"participantId"`
|
ParticipantID string `json:"participantId"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -133,19 +174,26 @@ type RoomLinks struct {
|
|||||||
AdminLink string `json:"adminLink,omitempty"`
|
AdminLink string `json:"adminLink,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicRoomState struct {
|
type PublicActivityLogEntry struct {
|
||||||
RoomID string `json:"roomId"`
|
At time.Time `json:"at"`
|
||||||
RoomName string `json:"roomName"`
|
Message string `json:"message"`
|
||||||
Cards []string `json:"cards"`
|
}
|
||||||
Revealed bool `json:"revealed"`
|
|
||||||
RevealMode string `json:"revealMode"`
|
type PublicRoomState struct {
|
||||||
MaxPeople int `json:"maxPeople"`
|
RoomID string `json:"roomId"`
|
||||||
AllowSpectators bool `json:"allowSpectators"`
|
RoomName string `json:"roomName"`
|
||||||
AnonymousVoting bool `json:"anonymousVoting"`
|
Cards []string `json:"cards"`
|
||||||
AutoReset bool `json:"autoReset"`
|
Revealed bool `json:"revealed"`
|
||||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
RevealMode string `json:"revealMode"`
|
||||||
Participants []PublicParticipant `json:"participants"`
|
MaxPeople int `json:"maxPeople"`
|
||||||
SelfParticipantID string `json:"selfParticipantId"`
|
AllowSpectators bool `json:"allowSpectators"`
|
||||||
ViewerIsAdmin bool `json:"viewerIsAdmin"`
|
AnonymousVoting bool `json:"anonymousVoting"`
|
||||||
Links RoomLinks `json:"links"`
|
AutoReset bool `json:"autoReset"`
|
||||||
|
AllowVoteChange bool `json:"allowVoteChange"`
|
||||||
|
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||||
|
Participants []PublicParticipant `json:"participants"`
|
||||||
|
SelfParticipantID string `json:"selfParticipantId"`
|
||||||
|
ViewerIsAdmin bool `json:"viewerIsAdmin"`
|
||||||
|
Links RoomLinks `json:"links"`
|
||||||
|
AdminLogs []PublicActivityLogEntry `json:"adminLogs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ func passwordMatches(password, salt, expectedHash string) bool {
|
|||||||
return subtle.ConstantTimeCompare([]byte(computed), []byte(expectedHash)) == 1
|
return subtle.ConstantTimeCompare([]byte(computed), []byte(expectedHash)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func secureTokenMatches(expected, provided string) bool {
|
||||||
|
if expected == "" || provided == "" || len(expected) != len(provided) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
func nowUTC() time.Time {
|
func nowUTC() time.Time {
|
||||||
return time.Now().UTC()
|
return time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,18 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<link rel="stylesheet" href="/static/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/cards.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/themes/win98.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/themes/modern.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/themes/no-theme.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="config">
|
<body data-page="config">
|
||||||
<div class="top-bar">
|
<div class="mobile-control-strip">
|
||||||
<button class="btn" id="theme-toggle" type="button">Dark Mode</button>
|
<div class="taskbar-shell">
|
||||||
|
<div class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="desktop">
|
<main id="desktop">
|
||||||
@@ -74,13 +81,25 @@
|
|||||||
<label for="voting-timeout">Voting timeout (seconds)</label>
|
<label for="voting-timeout">Voting timeout (seconds)</label>
|
||||||
<div class="number-input-wrap number-with-unit">
|
<div class="number-input-wrap number-with-unit">
|
||||||
<input type="number" id="voting-timeout" name="votingTimeoutSec" min="0" max="3600" value="{{ .DefaultVotingTime }}">
|
<input type="number" id="voting-timeout" name="votingTimeoutSec" min="0" max="3600" value="{{ .DefaultVotingTime }}">
|
||||||
<span class="input-unit">sec</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label for="room-password">Room password (optional)</label>
|
<label for="room-password">Room password (optional)</label>
|
||||||
<input type="password" id="room-password" name="password" maxlength="64" placeholder="Optional password">
|
<input
|
||||||
|
type="password"
|
||||||
|
id="room-password"
|
||||||
|
name="password"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="Optional password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-bwignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +117,10 @@
|
|||||||
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
|
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
|
||||||
<span>Auto-reset cards after each reveal</span>
|
<span>Auto-reset cards after each reveal</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="option-item">
|
||||||
|
<input type="checkbox" id="allow-vote-change" name="allowVoteChange" {{ if .AllowVoteChange }}checked{{ end }}>
|
||||||
|
<span>Allow participants to change their vote</span>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -122,8 +145,17 @@
|
|||||||
<div class="card-editor-row">
|
<div class="card-editor-row">
|
||||||
<input type="text" id="custom-card" maxlength="8" placeholder="e.g. 34 or ?">
|
<input type="text" id="custom-card" maxlength="8" placeholder="e.g. 34 or ?">
|
||||||
<button type="button" id="add-card" class="btn">Add</button>
|
<button type="button" id="add-card" class="btn">Add</button>
|
||||||
|
<button type="button" id="auto-sort" class="btn">Auto-sort</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-tools-row">
|
||||||
|
<button type="button" id="save-preset" class="btn">Save preset</button>
|
||||||
|
<button type="button" id="share-deck" class="btn">Share</button>
|
||||||
|
<button type="button" id="preset-picker-toggle" class="btn icon-btn" aria-expanded="false" aria-controls="preset-modal-overlay" title="Open Presets">
|
||||||
|
<img src="/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png" alt="Presets">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,8 +169,87 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div id="preset-modal-overlay" class="preset-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="preset-modal-title">
|
||||||
|
<section class="window preset-modal-window">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span id="preset-modal-title">Deck Presets</span>
|
||||||
|
<div class="title-bar-controls">
|
||||||
|
<button type="button" id="preset-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-content">
|
||||||
|
<div id="preset-list" class="preset-list"></div>
|
||||||
|
<div class="preset-modal-actions">
|
||||||
|
<button type="button" id="import-toggle" class="btn import-btn">Import</button>
|
||||||
|
<button type="button" id="preset-modal-done" class="btn">Done</button>
|
||||||
|
</div>
|
||||||
|
<div id="import-pane" class="import-pane hidden">
|
||||||
|
<textarea id="import-b64" rows="3" placeholder="Paste base64 deck string"></textarea>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="button" id="import-apply" class="btn">Apply Import</button>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
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">Theme.exe</span>
|
||||||
|
<div class="title-bar-controls">
|
||||||
|
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-content">
|
||||||
|
<p class="tool-copy">Pick a visual style for this workspace.</p>
|
||||||
|
<div class="theme-option-list">
|
||||||
|
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="win98">
|
||||||
|
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_100-0.png" alt="">
|
||||||
|
<span>Win98</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="modern">
|
||||||
|
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png" alt="">
|
||||||
|
<span>Modern</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="none">
|
||||||
|
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png" alt="">
|
||||||
|
<span>No Theme</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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="">
|
||||||
|
<span data-role="mode-toggle-label">Switch to Dark Mode</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="taskbar desktop-taskbar" aria-label="Desktop taskbar">
|
||||||
|
<div class="taskbar-shell">
|
||||||
|
<div class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/js/ui-controls.js"></script>
|
||||||
|
<script src="/static/js/cards.js"></script>
|
||||||
<script src="/static/js/config.js"></script>
|
<script src="/static/js/config.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,15 +7,41 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<link rel="stylesheet" href="/static/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/cards.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/themes/win98.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/themes/modern.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/themes/no-theme.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="room" data-room-id="{{ .RoomID }}">
|
<body data-page="room" data-room-id="{{ .RoomID }}" class="prejoin">
|
||||||
<div class="top-bar">
|
<div class="mobile-control-strip">
|
||||||
<button class="btn" id="theme-toggle" type="button">Dark Mode</button>
|
<div class="taskbar-shell">
|
||||||
|
<div class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="desktop" class="room-desktop">
|
<main id="desktop" class="room-desktop">
|
||||||
<section class="room-grid" aria-label="Scrum poker room board">
|
<section id="room-skeleton" class="room-grid skeleton-grid hidden" aria-hidden="true">
|
||||||
|
<article class="window room-main-window">
|
||||||
|
<div class="title-bar"><span>Loading room...</span></div>
|
||||||
|
<div class="window-content">
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-board"></div>
|
||||||
|
<div class="skeleton-line short"></div>
|
||||||
|
<div class="skeleton-table"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<aside class="window side-panel-window">
|
||||||
|
<div class="title-bar"><span>Loading participants...</span></div>
|
||||||
|
<div class="window-content">
|
||||||
|
<div class="skeleton-list"></div>
|
||||||
|
<div class="skeleton-controls"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="room-grid" class="room-grid hidden" aria-label="Scrum poker room board">
|
||||||
<article class="window room-main-window">
|
<article class="window room-main-window">
|
||||||
<div class="title-bar">
|
<div class="title-bar">
|
||||||
<span id="room-title">Room</span>
|
<span id="room-title">Room</span>
|
||||||
@@ -31,39 +57,72 @@
|
|||||||
<span id="round-state-label">Cards hidden</span>
|
<span id="round-state-label">Cards hidden</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="voting-board" id="voting-board"></div>
|
<div class="voting-board" id="voting-board"></div>
|
||||||
|
|
||||||
|
<section class="window vote-summary-window" id="vote-summary-window">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span>Vote Summary</span>
|
||||||
|
</div>
|
||||||
|
<div class="window-content vote-summary-content">
|
||||||
|
<table class="summary-table" id="summary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Card</th>
|
||||||
|
<th>Users</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="summary-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="summary-metrics">
|
||||||
|
<span id="summary-average">Average: -</span>
|
||||||
|
<span id="summary-recommended">Recommended: -</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<aside class="window participants-window">
|
<div class="side-stack">
|
||||||
<div class="title-bar">
|
<aside class="window side-panel-window participants-window">
|
||||||
<span>Participants</span>
|
<div class="title-bar">
|
||||||
</div>
|
<span>Participants</span>
|
||||||
<div class="window-content participants-content">
|
</div>
|
||||||
<ul id="participant-list" class="participant-list"></ul>
|
<div class="window-content side-panel-content">
|
||||||
</div>
|
<div class="participants-scroll">
|
||||||
</aside>
|
<ul id="participant-list" class="participant-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="participants-footer">
|
||||||
|
<p id="votes-counter" class="status-line">Votes: 0/0</p>
|
||||||
|
<button type="button" id="change-name-btn" class="btn">Change Name</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<section class="window control-window">
|
<aside class="window side-panel-window admin-window">
|
||||||
<div class="title-bar">
|
<div class="title-bar">
|
||||||
<span>Controls</span>
|
<span>Admin Controls</span>
|
||||||
</div>
|
|
||||||
<div class="window-content control-content">
|
|
||||||
<div class="links-block">
|
|
||||||
<label>Participant Link</label>
|
|
||||||
<input id="participant-link" type="text" readonly>
|
|
||||||
<label>Admin Link</label>
|
|
||||||
<input id="admin-link" type="text" readonly>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="admin-controls" class="admin-controls hidden">
|
<div class="window-content side-panel-content">
|
||||||
<button type="button" id="reveal-btn" class="btn">Reveal</button>
|
<div class="links-block">
|
||||||
<button type="button" id="reset-btn" class="btn">Reset</button>
|
<label for="share-link">Share Link</label>
|
||||||
|
<div class="share-link-row">
|
||||||
|
<input id="share-link" type="text" readonly>
|
||||||
|
<label class="share-admin-toggle">
|
||||||
|
<input id="share-admin-toggle" type="checkbox">
|
||||||
|
<span>Admin</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="admin-controls" class="admin-controls hidden">
|
||||||
|
<button type="button" id="reveal-btn" class="btn">Reveal</button>
|
||||||
|
<button type="button" id="reset-btn" class="btn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<p id="room-message" class="status-line">Waiting for join...</p>
|
||||||
</div>
|
</div>
|
||||||
<p id="room-status" class="status-line">Connecting...</p>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="join-panel" class="window join-window hidden" aria-label="Join room">
|
<section id="join-panel" class="window join-window" aria-label="Join room">
|
||||||
<div class="title-bar">
|
<div class="title-bar">
|
||||||
<span>JoinRoom.exe</span>
|
<span>JoinRoom.exe</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,8 +154,90 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<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">Theme.exe</span>
|
||||||
|
<div class="title-bar-controls">
|
||||||
|
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-content">
|
||||||
|
<p class="tool-copy">Pick a visual style for this workspace.</p>
|
||||||
|
<div class="theme-option-list">
|
||||||
|
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="win98">
|
||||||
|
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_100-0.png" alt="">
|
||||||
|
<span>Win98</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="modern">
|
||||||
|
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png" alt="">
|
||||||
|
<span>Modern</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="none">
|
||||||
|
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png" alt="">
|
||||||
|
<span>No Theme</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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="">
|
||||||
|
<span data-role="mode-toggle-label">Switch to Dark Mode</span>
|
||||||
|
</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>
|
</main>
|
||||||
|
|
||||||
|
<footer class="taskbar desktop-taskbar" aria-label="Desktop taskbar">
|
||||||
|
<div class="taskbar-shell">
|
||||||
|
<div class="taskbar-program-list" data-role="taskbar-program-list"></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/js/ui-controls.js"></script>
|
||||||
|
<script src="/static/js/cards.js"></script>
|
||||||
<script src="/static/js/room.js"></script>
|
<script src="/static/js/room.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
38
static/css/cards.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.vote-card,
|
||||||
|
.preview-card {
|
||||||
|
border: var(--card-border-width) solid var(--card-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--card-text);
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-corner {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-corner.top-left {
|
||||||
|
top: 0.34rem;
|
||||||
|
left: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-corner.bottom-right {
|
||||||
|
right: 0.34rem;
|
||||||
|
bottom: 0.34rem;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-center-icon {
|
||||||
|
z-index: 1;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
655
static/css/layout.css
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
:root {
|
||||||
|
--ui-scale: 1.06;
|
||||||
|
--base-font-size: clamp(16px, 0.35vw + 0.9rem, 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#desktop {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4.3rem 1rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-control-strip {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-shell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-program-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-program-btn {
|
||||||
|
min-height: 1.8rem;
|
||||||
|
max-width: min(14rem, 42vw);
|
||||||
|
padding: 0.18rem 0.5rem 0.18rem 0.34rem;
|
||||||
|
border: var(--control-border-width) solid var(--border-outer);
|
||||||
|
background: var(--surface-control);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--button-shadow);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-program-btn.is-active {
|
||||||
|
box-shadow: var(--button-shadow-active);
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-program-btn span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-taskbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tool-window {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 80;
|
||||||
|
top: 6.2rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: min(24rem, calc(100vw - 2rem));
|
||||||
|
height: 14rem;
|
||||||
|
min-width: 16.25rem;
|
||||||
|
min-height: 10rem;
|
||||||
|
resize: both;
|
||||||
|
overflow: auto;
|
||||||
|
max-width: calc(100vw - 1rem);
|
||||||
|
max-height: calc(100vh - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tool-title-bar {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tool-window .title-bar-controls button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-copy {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option-btn,
|
||||||
|
.mode-toggle-btn {
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option-btn.is-selected {
|
||||||
|
outline: var(--selected-outline);
|
||||||
|
outline-offset: -0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-dragging-window {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-dragging-window .ui-tool-title-bar {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-window {
|
||||||
|
width: min(78rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-copy {
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-with-unit .input-unit {
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-board {
|
||||||
|
min-height: 13rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
width: 3.15rem;
|
||||||
|
height: 4.45rem;
|
||||||
|
border-radius: 0.32rem;
|
||||||
|
user-select: none;
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 170ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card .card-center-icon {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card.dragging {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card.wiggle {
|
||||||
|
animation: wiggle 250ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wiggle {
|
||||||
|
0% { transform: rotate(-2deg); }
|
||||||
|
50% { transform: rotate(2deg); }
|
||||||
|
100% { transform: rotate(-2deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.35rem;
|
||||||
|
right: -0.35rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
|
transform: scale(0.86);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card:hover .preview-card-remove,
|
||||||
|
.preview-card-remove:focus {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card.is-removing {
|
||||||
|
animation: card-pop-out 190ms ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-pop-out {
|
||||||
|
from { opacity: 1; transform: scale(1); }
|
||||||
|
to { opacity: 0; transform: scale(0.58) rotate(-10deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-editor-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-tools-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 2.45rem;
|
||||||
|
min-width: 2.45rem;
|
||||||
|
height: 2.15rem;
|
||||||
|
padding: 0.1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn img {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 70;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-modal-window {
|
||||||
|
width: min(40rem, 92vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-height: 11rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-pane {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-pane textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-desktop {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-grid {
|
||||||
|
width: min(78rem, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-main-window {
|
||||||
|
min-height: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-window {
|
||||||
|
min-height: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting-board {
|
||||||
|
min-height: 14rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-card {
|
||||||
|
width: 4.3rem;
|
||||||
|
height: 6rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: visible;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-card .card-center-icon {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-card:hover {
|
||||||
|
transform: translateY(-0.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-summary-window {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-summary-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metrics {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-footer {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-controls {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-admin-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#share-link {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#votes-counter,
|
||||||
|
#room-message {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-window {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(27rem, 92vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line,
|
||||||
|
.skeleton-board,
|
||||||
|
.skeleton-table,
|
||||||
|
.skeleton-list,
|
||||||
|
.skeleton-controls {
|
||||||
|
height: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-board {
|
||||||
|
height: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-table {
|
||||||
|
height: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list {
|
||||||
|
height: 20rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-controls {
|
||||||
|
height: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.mobile-control-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-taskbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-taskbar .taskbar-program-btn {
|
||||||
|
min-width: 11.2rem;
|
||||||
|
max-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tool-window {
|
||||||
|
top: 4.25rem;
|
||||||
|
left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#desktop {
|
||||||
|
padding: 1rem 1rem 3.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.config-layout,
|
||||||
|
.room-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-main-window,
|
||||||
|
.participants-window,
|
||||||
|
.admin-window {
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#desktop {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 3.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-program-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-program-btn {
|
||||||
|
max-width: none;
|
||||||
|
width: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metrics {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.ui-tool-window {
|
||||||
|
top: 2.85rem !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: calc(100dvh - 2.85rem) !important;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: calc(100dvh - 2.85rem);
|
||||||
|
resize: none;
|
||||||
|
overflow: auto;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tool-title-bar {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2560px), (min-resolution: 2dppx) {
|
||||||
|
:root {
|
||||||
|
--ui-scale: 1.24;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
static/css/main.css
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: calc(var(--base-font-size) * var(--ui-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--font-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-control-strip,
|
||||||
|
.taskbar {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-window) 87%, #ffffff 13%) 0%,
|
||||||
|
color-mix(in srgb, var(--surface-window) 92%, #000000 8%) 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.45),
|
||||||
|
0 -1px 0 rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-control-strip {
|
||||||
|
border-bottom: var(--window-border-width) solid var(--border-outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar {
|
||||||
|
border-top: var(--window-border-width) solid var(--border-outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 45;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window {
|
||||||
|
background: var(--surface-window);
|
||||||
|
border: var(--window-border-width) solid var(--border-outer);
|
||||||
|
box-shadow: var(--window-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
background: var(--title-bg);
|
||||||
|
color: var(--title-text);
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar-controls button {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 0.6rem;
|
||||||
|
border: var(--control-border-width) solid var(--border-outer);
|
||||||
|
background: var(--surface-control);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-content {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: var(--control-border-width) solid var(--border-outer);
|
||||||
|
background: var(--surface-control);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--button-shadow);
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
box-shadow: var(--button-shadow-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="password"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--surface-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: var(--input-border-width) solid var(--border-input);
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line {
|
||||||
|
background: var(--surface-status);
|
||||||
|
border: var(--input-border-width) solid var(--border-input);
|
||||||
|
padding: 0.32rem 0.5rem;
|
||||||
|
min-height: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-list {
|
||||||
|
list-style: none;
|
||||||
|
background: var(--surface-input);
|
||||||
|
border: var(--input-border-width) solid var(--border-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-item {
|
||||||
|
border-bottom: 1px dashed var(--border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-name.is-admin {
|
||||||
|
color: #2f58ff;
|
||||||
|
text-shadow: 0 0 0.2rem rgba(47, 88, 255, 0.32);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--surface-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-table th,
|
||||||
|
.summary-table td {
|
||||||
|
border: 1px solid var(--border-muted);
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-card.is-selected {
|
||||||
|
outline: var(--selected-outline);
|
||||||
|
outline-offset: -0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-card.impact {
|
||||||
|
animation: vote-impact 320ms cubic-bezier(0.2, 0.85, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vote-impact {
|
||||||
|
0% { transform: translateX(0) scale(1) rotate(0deg); }
|
||||||
|
15% { transform: translateX(-0.18rem) scale(0.9) rotate(-2.2deg); }
|
||||||
|
30% { transform: translateX(0.14rem) scale(1.06) rotate(1.7deg); }
|
||||||
|
45% { transform: translateX(-0.1rem) scale(0.98) rotate(-1.4deg); }
|
||||||
|
60% { transform: translateX(0.08rem) scale(1.04) rotate(1deg); }
|
||||||
|
100% { transform: translateX(0) scale(1) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-particle {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 0.32rem;
|
||||||
|
height: 0.32rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: hsl(var(--hue, 120) 95% 66%);
|
||||||
|
box-shadow: 0 0 0.35rem hsl(var(--hue, 120) 95% 66%);
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
animation: vote-particle-burst 420ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vote-particle-burst {
|
||||||
|
0% {
|
||||||
|
opacity: 0.95;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(calc(-50% + var(--tx, 0px)), calc(-50% + var(--ty, 0px))) scale(0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-window .title-bar {
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #9dff9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-window .title-bar-controls button {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #9dff9d;
|
||||||
|
border-color: #2e2e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-log-output {
|
||||||
|
background: #020a02;
|
||||||
|
color: #84ff84;
|
||||||
|
border: 1px solid #0f4f0f;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-log-line {
|
||||||
|
margin-bottom: 0.18rem;
|
||||||
|
}
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
:root {
|
|
||||||
--desktop-bg: #008080;
|
|
||||||
--window-bg: #c0c0c0;
|
|
||||||
--window-text: #000000;
|
|
||||||
--border-light: #ffffff;
|
|
||||||
--border-dark: #000000;
|
|
||||||
--border-mid-light: #dfdfdf;
|
|
||||||
--border-mid-dark: #808080;
|
|
||||||
--title-bg: #000080;
|
|
||||||
--title-text: #ffffff;
|
|
||||||
--input-bg: #ffffff;
|
|
||||||
--status-bg: #b3b3b3;
|
|
||||||
--board-bg: #0f6d3d;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--card-text: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--desktop-bg: #0a0a0a;
|
|
||||||
--window-bg: #2b2b2b;
|
|
||||||
--window-text: #e0e0e0;
|
|
||||||
--border-light: #555555;
|
|
||||||
--border-dark: #000000;
|
|
||||||
--border-mid-light: #3a3a3a;
|
|
||||||
--border-mid-dark: #1a1a1a;
|
|
||||||
--title-bg: #000000;
|
|
||||||
--title-text: #00ff00;
|
|
||||||
--input-bg: #111111;
|
|
||||||
--status-bg: #1b1b1b;
|
|
||||||
--board-bg: #0b2f16;
|
|
||||||
--card-bg: #171717;
|
|
||||||
--card-text: #00ff66;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--desktop-bg);
|
|
||||||
color: var(--window-text);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.12) 1px, transparent 1px);
|
|
||||||
background-size: 4px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#desktop {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 28px 16px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
position: fixed;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window {
|
|
||||||
background-color: var(--window-bg);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light);
|
|
||||||
padding: 2px;
|
|
||||||
box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar {
|
|
||||||
background-color: var(--title-bg);
|
|
||||||
color: var(--title-text);
|
|
||||||
padding: 2px 4px;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar-controls {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar-controls button {
|
|
||||||
background: var(--window-bg);
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light);
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 10px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--window-text);
|
|
||||||
font-weight: bold;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-content {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group label,
|
|
||||||
legend {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="password"],
|
|
||||||
select {
|
|
||||||
background: var(--input-bg);
|
|
||||||
color: var(--window-text);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
|
||||||
padding: 5px 6px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus,
|
|
||||||
select:focus {
|
|
||||||
box-shadow: inset 0 0 0 1px var(--title-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"] {
|
|
||||||
appearance: textfield;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"]::-webkit-outer-spin-button,
|
|
||||||
input[type="number"]::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
|
||||||
padding: 0 6px;
|
|
||||||
min-height: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-wrap input[type="number"] {
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-with-unit {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-unit {
|
|
||||||
font-size: 1rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
min-width: 26px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
background: var(--window-bg);
|
|
||||||
color: var(--window-text);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light);
|
|
||||||
box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark);
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
|
||||||
box-shadow: inset 1px 1px var(--border-mid-dark), inset -1px -1px var(--border-mid-light);
|
|
||||||
padding: 5px 11px 3px 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
|
||||||
text-align: right;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-line {
|
|
||||||
background: var(--status-bg);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
min-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Config page */
|
|
||||||
.config-window {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 980px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-copy {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-box {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-box legend {
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-item input[type="checkbox"] {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
accent-color: #000080;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .option-item input[type="checkbox"] {
|
|
||||||
accent-color: #00aa00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-window {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-board {
|
|
||||||
background: var(--board-bg);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
|
||||||
border-radius: 2px;
|
|
||||||
min-height: 190px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-cards {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card {
|
|
||||||
position: relative;
|
|
||||||
width: 50px;
|
|
||||||
height: 72px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 2px solid #000;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--card-text);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5);
|
|
||||||
user-select: none;
|
|
||||||
transition: transform 180ms ease;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card-remove {
|
|
||||||
position: absolute;
|
|
||||||
top: -6px;
|
|
||||||
right: -6px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light);
|
|
||||||
background: #a40000;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 14px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.85);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 130ms ease, transform 130ms ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card:hover .preview-card-remove,
|
|
||||||
.preview-card-remove:focus {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card.is-removing {
|
|
||||||
animation: card-pop-out 190ms ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes card-pop-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.58) rotate(-10deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-editor label {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-editor-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Room page */
|
|
||||||
.room-desktop {
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-grid {
|
|
||||||
width: min(1180px, 100%);
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
grid-template-areas:
|
|
||||||
"main participants"
|
|
||||||
"controls participants";
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-main-window {
|
|
||||||
grid-area: main;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participants-window {
|
|
||||||
grid-area: participants;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-window {
|
|
||||||
grid-area: controls;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-board {
|
|
||||||
background: var(--board-bg);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
|
||||||
min-height: 260px;
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
align-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-card {
|
|
||||||
width: 72px;
|
|
||||||
height: 100px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 2px solid #000;
|
|
||||||
background: var(--card-bg);
|
|
||||||
color: var(--card-text);
|
|
||||||
font-size: 2rem;
|
|
||||||
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.45);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-card:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-card.is-selected {
|
|
||||||
outline: 3px solid #ffd200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participants-content {
|
|
||||||
max-height: 520px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-list {
|
|
||||||
list-style: none;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 2px solid;
|
|
||||||
border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark);
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 5px;
|
|
||||||
border-bottom: 1px dashed var(--border-mid-dark);
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-item:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-block {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-block input {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-window {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 30;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: min(420px, 92vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
#join-error {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.config-layout,
|
|
||||||
.room-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-grid {
|
|
||||||
grid-template-areas:
|
|
||||||
"main"
|
|
||||||
"participants"
|
|
||||||
"controls";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
#desktop {
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-top: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
228
static/css/themes/modern.css
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
:root[data-ui-theme="modern"] {
|
||||||
|
--font-main: "Segoe UI Variable Text", "Segoe UI", "Inter", "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--desktop-bg: #282c34;
|
||||||
|
--desktop-pattern:
|
||||||
|
radial-gradient(circle at 14% 18%, rgba(97, 175, 239, 0.18) 0, rgba(97, 175, 239, 0) 46%),
|
||||||
|
radial-gradient(circle at 88% 10%, rgba(198, 120, 221, 0.18) 0, rgba(198, 120, 221, 0) 40%),
|
||||||
|
radial-gradient(circle at 54% 100%, rgba(86, 182, 194, 0.13) 0, rgba(86, 182, 194, 0) 52%),
|
||||||
|
linear-gradient(165deg, #2c313c 0%, #232731 100%);
|
||||||
|
--surface-window: rgba(40, 44, 52, 0.92);
|
||||||
|
--surface-control: #3a404b;
|
||||||
|
--surface-input: #21252b;
|
||||||
|
--surface-status: #2f343f;
|
||||||
|
--text-primary: #abb2bf;
|
||||||
|
--title-bg: linear-gradient(90deg, #353b46 0%, #2c313c 100%);
|
||||||
|
--title-text: #e6edf7;
|
||||||
|
--border-outer: #4b5263;
|
||||||
|
--border-input: #5c6370;
|
||||||
|
--border-muted: #3a404b;
|
||||||
|
--window-border-width: 1px;
|
||||||
|
--control-border-width: 1px;
|
||||||
|
--input-border-width: 1px;
|
||||||
|
--window-shadow: 0 18px 45px rgba(14, 16, 20, 0.42);
|
||||||
|
--button-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
|
||||||
|
--button-shadow-active: inset 0 1px 0 rgba(0, 0, 0, 0.25);
|
||||||
|
--focus-ring: 0 0 0 2px rgba(97, 175, 239, 0.35);
|
||||||
|
--card-bg: #21252b;
|
||||||
|
--card-text: #e6edf7;
|
||||||
|
--card-border: #5c6370;
|
||||||
|
--card-border-width: 1px;
|
||||||
|
--selected-outline: 2px solid #61afef;
|
||||||
|
--modal-overlay: rgba(10, 12, 16, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"][data-theme="dark"] {
|
||||||
|
--desktop-bg: #21252b;
|
||||||
|
--desktop-pattern:
|
||||||
|
radial-gradient(circle at 12% 16%, rgba(97, 175, 239, 0.16) 0, rgba(97, 175, 239, 0) 46%),
|
||||||
|
radial-gradient(circle at 86% 8%, rgba(198, 120, 221, 0.16) 0, rgba(198, 120, 221, 0) 42%),
|
||||||
|
linear-gradient(165deg, #252932 0%, #1d2027 100%);
|
||||||
|
--surface-window: rgba(33, 37, 43, 0.95);
|
||||||
|
--surface-control: #343a45;
|
||||||
|
--surface-input: #1d2127;
|
||||||
|
--surface-status: #2a2f38;
|
||||||
|
--text-primary: #b9c2d0;
|
||||||
|
--title-bg: linear-gradient(90deg, #2f343f 0%, #272c35 100%);
|
||||||
|
--title-text: #eef3fb;
|
||||||
|
--border-outer: #5c6370;
|
||||||
|
--border-input: #6c7484;
|
||||||
|
--border-muted: #404652;
|
||||||
|
--window-shadow: 0 20px 52px rgba(8, 10, 13, 0.58);
|
||||||
|
--focus-ring: 0 0 0 2px rgba(97, 175, 239, 0.42);
|
||||||
|
--card-bg: #1d2127;
|
||||||
|
--card-text: #eef3fb;
|
||||||
|
--card-border: #6c7484;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] body {
|
||||||
|
background-color: var(--desktop-bg);
|
||||||
|
background-image: var(--desktop-pattern);
|
||||||
|
background-size: cover;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .window,
|
||||||
|
:root[data-ui-theme="modern"] .vote-card,
|
||||||
|
:root[data-ui-theme="modern"] .preview-card,
|
||||||
|
:root[data-ui-theme="modern"] .btn,
|
||||||
|
:root[data-ui-theme="modern"] input,
|
||||||
|
:root[data-ui-theme="modern"] select,
|
||||||
|
:root[data-ui-theme="modern"] textarea {
|
||||||
|
border-radius: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .window {
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .mobile-control-strip,
|
||||||
|
:root[data-ui-theme="modern"] .taskbar {
|
||||||
|
background: rgba(40, 44, 52, 0.82);
|
||||||
|
border-top-color: var(--border-input);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .title-bar {
|
||||||
|
font-size: 0.96rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
border-bottom: 1px solid rgba(97, 175, 239, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .title-bar-controls button {
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
border-color: var(--border-input);
|
||||||
|
background: #323843;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .title-bar-controls button:hover,
|
||||||
|
:root[data-ui-theme="modern"] .btn:hover {
|
||||||
|
background: #434a57;
|
||||||
|
border-color: #7d8699;
|
||||||
|
color: #e6edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .btn {
|
||||||
|
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .btn-primary {
|
||||||
|
background: #61afef;
|
||||||
|
border-color: #61afef;
|
||||||
|
color: #10131a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .btn-primary:hover {
|
||||||
|
background: #79bcf3;
|
||||||
|
border-color: #79bcf3;
|
||||||
|
color: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] input::placeholder,
|
||||||
|
:root[data-ui-theme="modern"] textarea::placeholder {
|
||||||
|
color: #7d8699;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .status-line,
|
||||||
|
:root[data-ui-theme="modern"] .summary-table,
|
||||||
|
:root[data-ui-theme="modern"] .participant-list {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .summary-table th {
|
||||||
|
color: #61afef;
|
||||||
|
background: #272c35;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .participant-item:hover {
|
||||||
|
background: rgba(97, 175, 239, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .participant-name.is-admin {
|
||||||
|
color: #d19a66;
|
||||||
|
text-shadow: 0 0 0.25rem rgba(209, 154, 102, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .preview-board,
|
||||||
|
:root[data-ui-theme="modern"] .voting-board {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(97, 175, 239, 0.07) 0%, rgba(33, 37, 43, 0) 34%),
|
||||||
|
linear-gradient(0deg, rgba(92, 99, 112, 0.08), rgba(92, 99, 112, 0.08)),
|
||||||
|
#20242b;
|
||||||
|
border: 1px solid #5c6370;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(97, 175, 239, 0.15), inset 0 -20px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"][data-theme="dark"] .preview-board,
|
||||||
|
:root[data-ui-theme="modern"][data-theme="dark"] .voting-board {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(97, 175, 239, 0.07) 0%, rgba(33, 37, 43, 0) 35%),
|
||||||
|
linear-gradient(0deg, rgba(108, 116, 132, 0.08), rgba(108, 116, 132, 0.08)),
|
||||||
|
#1b1f25;
|
||||||
|
border-color: #6c7484;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .vote-card,
|
||||||
|
:root[data-ui-theme="modern"] .preview-card {
|
||||||
|
box-shadow: 0 5px 16px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .vote-card:hover {
|
||||||
|
border-color: #61afef;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .vote-card.is-selected {
|
||||||
|
box-shadow: 0 0 0 1px #61afef, 0 10px 22px rgba(97, 175, 239, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .hint-text {
|
||||||
|
color: #7d8699;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .icon-btn img {
|
||||||
|
image-rendering: auto;
|
||||||
|
filter: saturate(0.9) brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .preset-modal-overlay {
|
||||||
|
background: var(--modal-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .skeleton-line,
|
||||||
|
:root[data-ui-theme="modern"] .skeleton-board,
|
||||||
|
:root[data-ui-theme="modern"] .skeleton-table,
|
||||||
|
:root[data-ui-theme="modern"] .skeleton-list,
|
||||||
|
:root[data-ui-theme="modern"] .skeleton-controls {
|
||||||
|
background: linear-gradient(90deg, rgba(92, 99, 112, 0.22), rgba(122, 132, 148, 0.32), rgba(92, 99, 112, 0.22));
|
||||||
|
background-size: 220% 100%;
|
||||||
|
border: 1px solid #4b5263;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
animation: modern-skeleton-shimmer 1.2s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modern-skeleton-shimmer {
|
||||||
|
from { background-position: 0 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .terminal-window .title-bar {
|
||||||
|
background: #21252b;
|
||||||
|
color: #98c379;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .terminal-window .title-bar-controls button {
|
||||||
|
background: #2f343f;
|
||||||
|
color: #98c379;
|
||||||
|
border-color: #5c6370;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="modern"] .terminal-log-output {
|
||||||
|
background: #1b1f25;
|
||||||
|
color: #abb2bf;
|
||||||
|
border: 1px solid #4b5263;
|
||||||
|
font-family: "JetBrains Mono", "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
66
static/css/themes/no-theme.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
:root[data-ui-theme="none"] {
|
||||||
|
--font-main: ui-monospace, monospace;
|
||||||
|
--desktop-bg: #ffffff;
|
||||||
|
--desktop-pattern: none;
|
||||||
|
--surface-window: transparent;
|
||||||
|
--surface-control: #f5f5f5;
|
||||||
|
--surface-input: #ffffff;
|
||||||
|
--surface-status: #ffffff;
|
||||||
|
--text-primary: #000000;
|
||||||
|
--title-bg: transparent;
|
||||||
|
--title-text: #000000;
|
||||||
|
--border-outer: #bdbdbd;
|
||||||
|
--border-input: #bdbdbd;
|
||||||
|
--border-muted: #d3d3d3;
|
||||||
|
--window-border-width: 1px;
|
||||||
|
--control-border-width: 1px;
|
||||||
|
--input-border-width: 1px;
|
||||||
|
--window-shadow: none;
|
||||||
|
--button-shadow: none;
|
||||||
|
--button-shadow-active: none;
|
||||||
|
--focus-ring: 0 0 0 1px #777777;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-text: #000000;
|
||||||
|
--card-border: #bdbdbd;
|
||||||
|
--card-border-width: 1px;
|
||||||
|
--selected-outline: 1px dashed #000000;
|
||||||
|
--modal-overlay: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="none"][data-theme="dark"] {
|
||||||
|
--desktop-bg: #111111;
|
||||||
|
--surface-window: transparent;
|
||||||
|
--surface-control: #232323;
|
||||||
|
--surface-input: #161616;
|
||||||
|
--surface-status: #161616;
|
||||||
|
--text-primary: #e8e8e8;
|
||||||
|
--title-bg: transparent;
|
||||||
|
--title-text: #e8e8e8;
|
||||||
|
--border-outer: #4a4a4a;
|
||||||
|
--border-input: #4a4a4a;
|
||||||
|
--border-muted: #3a3a3a;
|
||||||
|
--focus-ring: 0 0 0 1px #9a9a9a;
|
||||||
|
--card-bg: #161616;
|
||||||
|
--card-text: #e8e8e8;
|
||||||
|
--card-border: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="none"] body {
|
||||||
|
background-color: var(--desktop-bg);
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="none"] .title-bar-controls,
|
||||||
|
:root[data-ui-theme="none"] .icon-btn img {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="none"] .window,
|
||||||
|
:root[data-ui-theme="none"] .preview-board,
|
||||||
|
:root[data-ui-theme="none"] .voting-board {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="none"] .preset-modal-overlay {
|
||||||
|
background: var(--modal-overlay);
|
||||||
|
}
|
||||||
88
static/css/themes/win98.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
:root:not([data-ui-theme]),
|
||||||
|
:root[data-ui-theme="win98"] {
|
||||||
|
--font-main: 'VT323', monospace;
|
||||||
|
--desktop-bg: #008080;
|
||||||
|
--desktop-pattern: radial-gradient(circle, rgba(0, 0, 0, 0.12) 1px, transparent 1px);
|
||||||
|
--surface-window: #c0c0c0;
|
||||||
|
--surface-control: #c0c0c0;
|
||||||
|
--surface-input: #ffffff;
|
||||||
|
--surface-status: #b3b3b3;
|
||||||
|
--text-primary: #000000;
|
||||||
|
--title-bg: #000080;
|
||||||
|
--title-text: #ffffff;
|
||||||
|
--border-outer: #000000;
|
||||||
|
--border-input: #000000;
|
||||||
|
--border-muted: #808080;
|
||||||
|
--window-border-width: 2px;
|
||||||
|
--control-border-width: 1px;
|
||||||
|
--input-border-width: 2px;
|
||||||
|
--window-shadow: inset 1px 1px #dfdfdf, inset -1px -1px #808080;
|
||||||
|
--button-shadow: inset 1px 1px #dfdfdf, inset -1px -1px #808080;
|
||||||
|
--button-shadow-active: inset 1px 1px #808080, inset -1px -1px #dfdfdf;
|
||||||
|
--focus-ring: inset 0 0 0 1px #000080;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-text: #000000;
|
||||||
|
--card-border: #000000;
|
||||||
|
--card-border-width: 2px;
|
||||||
|
--selected-outline: 2px dotted currentColor;
|
||||||
|
--modal-overlay: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="win98"][data-theme="dark"] {
|
||||||
|
--desktop-bg: #0a0a0a;
|
||||||
|
--desktop-pattern: radial-gradient(circle, rgba(0, 255, 80, 0.08) 1px, transparent 1px);
|
||||||
|
--surface-window: #2b2b2b;
|
||||||
|
--surface-control: #2b2b2b;
|
||||||
|
--surface-input: #111111;
|
||||||
|
--surface-status: #1b1b1b;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--title-bg: #000000;
|
||||||
|
--title-text: #00ff66;
|
||||||
|
--border-outer: #000000;
|
||||||
|
--border-input: #555555;
|
||||||
|
--border-muted: #3b3b3b;
|
||||||
|
--window-shadow: inset 1px 1px #3a3a3a, inset -1px -1px #1a1a1a;
|
||||||
|
--button-shadow: inset 1px 1px #3a3a3a, inset -1px -1px #1a1a1a;
|
||||||
|
--button-shadow-active: inset 1px 1px #1a1a1a, inset -1px -1px #3a3a3a;
|
||||||
|
--focus-ring: inset 0 0 0 1px #00ff66;
|
||||||
|
--card-bg: #171717;
|
||||||
|
--card-text: #00ff66;
|
||||||
|
--card-border: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--desktop-bg);
|
||||||
|
background-image: var(--desktop-pattern);
|
||||||
|
background-size: 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-board,
|
||||||
|
.voting-board {
|
||||||
|
background: #0f6d3d;
|
||||||
|
border: 2px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme="win98"][data-theme="dark"] .preview-board,
|
||||||
|
:root[data-ui-theme="win98"][data-theme="dark"] .voting-board {
|
||||||
|
background: #0a2c14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-modal-overlay {
|
||||||
|
background: var(--modal-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line,
|
||||||
|
.skeleton-board,
|
||||||
|
.skeleton-table,
|
||||||
|
.skeleton-list,
|
||||||
|
.skeleton-controls {
|
||||||
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0.08));
|
||||||
|
background-size: 220% 100%;
|
||||||
|
border: 1px solid var(--border-muted);
|
||||||
|
animation: skeleton-shimmer 1.2s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
from { background-position: 0 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
BIN
static/img/Windows Icons - PNG/COMMDLG.DLL_14_528-0.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
static/img/Windows Icons - PNG/COMMDLG.DLL_14_528-1.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
static/img/Windows Icons - PNG/COMMDLG.DLL_14_528-2.png
Normal file
|
After Width: | Height: | Size: 447 B |
BIN
static/img/Windows Icons - PNG/COMMDLG.DLL_14_529-0.png
Normal file
|
After Width: | Height: | Size: 422 B |
BIN
static/img/Windows Icons - PNG/COMMDLG.DLL_14_529-1.png
Normal file
|
After Width: | Height: | Size: 422 B |
BIN
static/img/Windows Icons - PNG/COMMDLG.DLL_14_529-2.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
static/img/Windows Icons - PNG/EXCH_regtrace.exe_14_128-0.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
static/img/Windows Icons - PNG/EXCH_regtrace.exe_14_128-1.png
Normal file
|
After Width: | Height: | Size: 380 B |
BIN
static/img/Windows Icons - PNG/EqnClass.Dll_14_101-0.png
Normal file
|
After Width: | Height: | Size: 562 B |
BIN
static/img/Windows Icons - PNG/EqnClass.Dll_14_101-1.png
Normal file
|
After Width: | Height: | Size: 394 B |
BIN
static/img/Windows Icons - PNG/EqnClass.Dll_14_110.png
Normal file
|
After Width: | Height: | Size: 361 B |
BIN
static/img/Windows Icons - PNG/EqnClass.Dll_14_111.png
Normal file
|
After Width: | Height: | Size: 374 B |
BIN
static/img/Windows Icons - PNG/EqnClass.Dll_14_112.png
Normal file
|
After Width: | Height: | Size: 367 B |
BIN
static/img/Windows Icons - PNG/EqnClass.Dll_14_114.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-0.png
Normal file
|
After Width: | Height: | Size: 921 B |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-1.png
Normal file
|
After Width: | Height: | Size: 731 B |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-10.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-11.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-2.png
Normal file
|
After Width: | Height: | Size: 578 B |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-3.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-4.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-5.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-6.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-7.png
Normal file
|
After Width: | Height: | Size: 811 B |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-8.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
static/img/Windows Icons - PNG/HelpCtr.exe_14_200-9.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/img/Windows Icons - PNG/MSCTFIME.IME_14_256.png
Normal file
|
After Width: | Height: | Size: 467 B |
BIN
static/img/Windows Icons - PNG/MSCTFIME.IME_14_257-0.png
Normal file
|
After Width: | Height: | Size: 630 B |
BIN
static/img/Windows Icons - PNG/MSCTFIME.IME_14_257-1.png
Normal file
|
After Width: | Height: | Size: 445 B |
BIN
static/img/Windows Icons - PNG/MSVIDEO.DLL_14_943-0.png
Normal file
|
After Width: | Height: | Size: 499 B |
BIN
static/img/Windows Icons - PNG/MSVIDEO.DLL_14_943-1.png
Normal file
|
After Width: | Height: | Size: 566 B |
BIN
static/img/Windows Icons - PNG/MSVIDEO.DLL_14_943-2.png
Normal file
|
After Width: | Height: | Size: 602 B |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-0.png
Normal file
|
After Width: | Height: | Size: 700 B |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-1.png
Normal file
|
After Width: | Height: | Size: 573 B |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-2.png
Normal file
|
After Width: | Height: | Size: 401 B |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-3.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-4.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-5.png
Normal file
|
After Width: | Height: | Size: 830 B |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-6.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-7.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
static/img/Windows Icons - PNG/NOTEPAD.EXE_14_2-8.png
Normal file
|
After Width: | Height: | Size: 880 B |
BIN
static/img/Windows Icons - PNG/PS5UI.DLL_14_1000-0.png
Normal file
|
After Width: | Height: | Size: 723 B |
BIN
static/img/Windows Icons - PNG/PS5UI.DLL_14_1000-1.png
Normal file
|
After Width: | Height: | Size: 424 B |
BIN
static/img/Windows Icons - PNG/PS5UI.DLL_14_2000-0.png
Normal file
|
After Width: | Height: | Size: 499 B |
BIN
static/img/Windows Icons - PNG/PS5UI.DLL_14_2000-1.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
static/img/Windows Icons - PNG/SOFTKBD.DLL_14_100.png
Normal file
|
After Width: | Height: | Size: 367 B |
BIN
static/img/Windows Icons - PNG/SOFTKBD.DLL_14_101.png
Normal file
|
After Width: | Height: | Size: 367 B |
BIN
static/img/Windows Icons - PNG/SPGRMR.dll_14_100.png
Normal file
|
After Width: | Height: | Size: 334 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_1.png
Normal file
|
After Width: | Height: | Size: 386 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_10-0.png
Normal file
|
After Width: | Height: | Size: 461 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_10-1.png
Normal file
|
After Width: | Height: | Size: 391 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_11-0.png
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_11-1.png
Normal file
|
After Width: | Height: | Size: 344 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_2.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_3.png
Normal file
|
After Width: | Height: | Size: 406 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_4.png
Normal file
|
After Width: | Height: | Size: 427 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_5.png
Normal file
|
After Width: | Height: | Size: 389 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_6.png
Normal file
|
After Width: | Height: | Size: 365 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_7.png
Normal file
|
After Width: | Height: | Size: 381 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_8.png
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_9-0.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
static/img/Windows Icons - PNG/SPTIP.dll_14_9-1.png
Normal file
|
After Width: | Height: | Size: 372 B |
BIN
static/img/Windows Icons - PNG/TPPrnUI.DLL_14_101_0.png
Normal file
|
After Width: | Height: | Size: 433 B |
BIN
static/img/Windows Icons - PNG/TPPrnUI.DLL_14_127_0.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/img/Windows Icons - PNG/TPPrnUI.DLL_14_131_0.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
static/img/Windows Icons - PNG/TPPrnUI.DLL_14_132_0.png
Normal file
|
After Width: | Height: | Size: 396 B |
BIN
static/img/Windows Icons - PNG/TPPrnUI.DLL_14_133_0.png
Normal file
|
After Width: | Height: | Size: 407 B |
BIN
static/img/Windows Icons - PNG/TPPrnUI.DLL_14_134_0.png
Normal file
|
After Width: | Height: | Size: 410 B |
20603
static/img/Windows Icons - PNG/_files.txt
Normal file
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-0.png
Normal file
|
After Width: | Height: | Size: 702 B |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-1.png
Normal file
|
After Width: | Height: | Size: 579 B |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-2.png
Normal file
|
After Width: | Height: | Size: 391 B |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-3.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-4.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-5.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-6.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-7.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/img/Windows Icons - PNG/access.cpl_14_110-8.png
Normal file
|
After Width: | Height: | Size: 900 B |
BIN
static/img/Windows Icons - PNG/acctres.dll_14_100-0.png
Normal file
|
After Width: | Height: | Size: 587 B |
BIN
static/img/Windows Icons - PNG/acctres.dll_14_100-1.png
Normal file
|
After Width: | Height: | Size: 420 B |
BIN
static/img/Windows Icons - PNG/acctres.dll_14_101-0.png
Normal file
|
After Width: | Height: | Size: 715 B |