mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19bec7ee5 | ||
|
|
987ef29f30 | ||
|
|
ba4cf80f41 | ||
|
|
622cd2875c | ||
|
|
98ca2e0626 | ||
|
|
dbc5111c53 | ||
|
|
c30325afc0 | ||
|
|
12512b8856 | ||
|
|
bb3197ca67 | ||
|
|
0b1551cabf | ||
|
|
69198a5556 | ||
|
|
f3a5e7bf77 | ||
|
|
d2acd9f19a | ||
|
|
94c3939671 | ||
|
|
f7de6d52be | ||
|
|
fff07ffbe6 | ||
|
|
9c046cc3b1 | ||
|
|
c465c03e34 | ||
|
|
99a1c5418d | ||
|
|
2fc9becc88 | ||
|
|
fe94c38963 | ||
|
|
55c72e0d9b | ||
|
|
a237d32d58 | ||
|
|
213630cb68 | ||
|
|
eb85b85b8f | ||
|
|
0600b8552c | ||
|
|
662efb300b | ||
|
|
e2f94720ec | ||
|
|
bcfb2946cf | ||
|
|
02603aa664 | ||
|
|
c67c3dc761 | ||
|
|
26d7335c9a | ||
|
|
a99e6e5286 | ||
|
|
d93d78cb2b | ||
|
|
e01878dd4e | ||
|
|
598f353fe5 | ||
|
|
7249621ebe | ||
|
|
6009056edd | ||
|
|
fd7b858395 | ||
|
|
1fd0de5b1e | ||
|
|
720a010973 | ||
|
|
2564a451b5 | ||
|
|
8326898403 | ||
|
|
e0edbcfb28 | ||
|
|
4f60248bc6 | ||
|
|
629d979b67 | ||
|
|
59c19f1da9 | ||
|
|
b9a007bd48 | ||
|
|
850ac3ec15 | ||
|
|
87dd9a24a0 | ||
|
|
bdcb488988 | ||
|
|
9e6053d5c0 | ||
|
|
4d699e1c4c | ||
|
|
5e7581c82d | ||
|
|
f7e4269959 | ||
|
|
f3f1db1398 | ||
|
|
133c061ee9 | ||
|
|
6859a6c048 | ||
|
|
465bddb9dd | ||
|
|
c7a639a568 | ||
|
|
df952b27e0 | ||
|
|
36f28a9708 |
@@ -11,6 +11,7 @@ config.h
|
|||||||
robots.json
|
robots.json
|
||||||
roverd-dummy
|
roverd-dummy
|
||||||
server/config.yaml
|
server/config.yaml
|
||||||
|
server/package-lock.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
server/data/discord-guilds.json
|
server/data/discord-guilds.json
|
||||||
server/data/community-goal.json
|
server/data/community-goal.json
|
||||||
|
|||||||
Vendored
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
#configuration for roverd
|
#configuration for roverd
|
||||||
name: dummy2
|
name: dummy2
|
||||||
serverUrl: ws://192.168.0.84:8080/rover
|
serverUrl: ws://127.0.0.1:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyS0
|
device: /dev/ttyS0
|
||||||
baud: 115200
|
baud: 115200
|
||||||
@@ -18,3 +18,5 @@ media:
|
|||||||
service: mediamtx.service
|
service: mediamtx.service
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
|
nightVision:
|
||||||
|
enabled: false
|
||||||
|
|||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#configuration for roverd
|
||||||
|
name: dummy3
|
||||||
|
serverUrl: ws://127.0.0.1:8080/rover
|
||||||
|
serial:
|
||||||
|
device: /dev/ttyS0
|
||||||
|
baud: 115200
|
||||||
|
brc:
|
||||||
|
gpioPin: 25
|
||||||
|
pulseEvery: 1m
|
||||||
|
pulseWidth: 1s
|
||||||
|
battery:
|
||||||
|
full: 2068
|
||||||
|
warn: 1700
|
||||||
|
urgent: 1650
|
||||||
|
maxWheelSpeed: 350
|
||||||
|
media:
|
||||||
|
manage: false
|
||||||
|
service: mediamtx.service
|
||||||
|
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||||
|
healthInterval: 30s
|
||||||
|
nightVision:
|
||||||
|
enabled: false
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+113
-52
@@ -1,83 +1,144 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Disable history expansion so PUBLISH_URL values with "!" are safe when sourcing env files.
|
|
||||||
|
# Keep history expansion off so values containing "!" are safe.
|
||||||
set +H
|
set +H
|
||||||
|
|
||||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
|
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# shellcheck disable=SC1090
|
# Load KEY=VALUE pairs from ENV_FILE WITHOUT evaluating shell metacharacters.
|
||||||
source "$ENV_FILE"
|
# This makes URLs containing characters like '&' and '#!' safe without requiring quoting.
|
||||||
|
load_env_file() {
|
||||||
|
local content=""
|
||||||
|
|
||||||
|
if [[ -r "$ENV_FILE" ]]; then
|
||||||
|
content="$(cat "$ENV_FILE")"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
# Try to read via sudo without prompting (useful when the service runs as an unprivileged user)
|
||||||
|
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$content" ]]; then
|
||||||
|
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local line key val
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
# Skip blank lines and full-line comments.
|
||||||
|
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
|
||||||
|
# Support optional leading 'export '
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
val="${BASH_REMATCH[2]}"
|
||||||
|
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
val="${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
# Ignore anything that isn't a simple assignment.
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trim leading/trailing whitespace in value.
|
||||||
|
val="${val#${val%%[![:space:]]*}}"
|
||||||
|
val="${val%${val##*[![:space:]]}}"
|
||||||
|
|
||||||
|
# If value is wrapped in matching single or double quotes, unwrap.
|
||||||
|
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||||
|
val="${val:1:${#val}-2}"
|
||||||
|
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||||
|
val="${val:1:${#val}-2}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Assign without evaluation.
|
||||||
|
printf -v "$key" '%s' "$val"
|
||||||
|
export "$key"
|
||||||
|
done <<< "$content"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file
|
||||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
||||||
|
|
||||||
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
|
# Defaults tuned for OV5647: use 4:3 output and force the common 2x2 binned full-FOV mode.
|
||||||
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
VIDEO_WIDTH="640"
|
||||||
VIDEO_FPS="${VIDEO_FPS:-30}"
|
VIDEO_HEIGHT="480"
|
||||||
|
VIDEO_FPS="30"
|
||||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||||
|
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
||||||
|
|
||||||
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
||||||
FLIP_ARGS=(--rotation 180)
|
FLIP_ARGS=(--rotation 180)
|
||||||
|
|
||||||
|
MODE_ARGS=()
|
||||||
|
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
||||||
|
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}")
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
||||||
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
||||||
elif command -v rpicam-vid >/dev/null 2>&1; then
|
elif command -v rpicam-vid >/dev/null 2>&1; then
|
||||||
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
|
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
|
||||||
elif command -v libcamera-vid >/dev/null 2>&1; then
|
elif command -v libcamera-vid >/dev/null 2>&1; then
|
||||||
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
|
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
|
||||||
else
|
else
|
||||||
echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2
|
echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||||
else
|
else
|
||||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_pipeline() {
|
run_pipeline() {
|
||||||
"${LIBCAMERA_BIN_PATH}" \
|
"${LIBCAMERA_BIN_PATH}" \
|
||||||
--inline \
|
--inline \
|
||||||
--timeout 0 \
|
--timeout 0 \
|
||||||
--width "${VIDEO_WIDTH}" \
|
"${MODE_ARGS[@]}" \
|
||||||
--height "${VIDEO_HEIGHT}" \
|
--width "${VIDEO_WIDTH}" \
|
||||||
"${FLIP_ARGS[@]}" \
|
--height "${VIDEO_HEIGHT}" \
|
||||||
--framerate "${VIDEO_FPS}" \
|
"${FLIP_ARGS[@]}" \
|
||||||
--bitrate "${VIDEO_BITRATE}" \
|
--framerate "${VIDEO_FPS}" \
|
||||||
--codec h264 \
|
--bitrate "${VIDEO_BITRATE}" \
|
||||||
--profile baseline \
|
--codec h264 \
|
||||||
--denoise auto \
|
--profile baseline \
|
||||||
--nopreview \
|
--denoise auto \
|
||||||
--metering centre \
|
--nopreview \
|
||||||
--ev 0.1 \
|
--metering centre \
|
||||||
--awb auto \
|
--ev 0.1 \
|
||||||
--saturation 0.6 \
|
--awb auto \
|
||||||
--brightness 0 \
|
--saturation 0.6 \
|
||||||
--output - \
|
--brightness 0 \
|
||||||
| "${FFMPEG_BIN_PATH}" \
|
--output - \
|
||||||
-hide_banner \
|
| "${FFMPEG_BIN_PATH}" \
|
||||||
-loglevel warning \
|
-hide_banner \
|
||||||
-fflags nobuffer \
|
-loglevel warning \
|
||||||
-use_wallclock_as_timestamps 1 \
|
-fflags nobuffer \
|
||||||
-f h264 \
|
-use_wallclock_as_timestamps 1 \
|
||||||
-i pipe:0 \
|
-f h264 \
|
||||||
-c:v copy \
|
-i pipe:0 \
|
||||||
-an \
|
-c:v copy \
|
||||||
-flush_packets 1 \
|
-an \
|
||||||
-f mpegts \
|
-flush_packets 1 \
|
||||||
"${PUBLISH_URL}"
|
-f mpegts \
|
||||||
|
"${PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if run_pipeline; then
|
if run_pipeline; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Video publisher exited, restarting in 2s..." >&2
|
echo "Video publisher exited, restarting in 2s..." >&2
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -215,9 +215,6 @@ cat > /var/lib/roverd/video.env <<'ENV'
|
|||||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
VIDEO_WIDTH=1280
|
|
||||||
VIDEO_HEIGHT=720
|
|
||||||
VIDEO_FPS=30
|
|
||||||
VIDEO_BITRATE=2000000
|
VIDEO_BITRATE=2000000
|
||||||
AUDIO_ENABLE=0
|
AUDIO_ENABLE=0
|
||||||
AUDIO_DEVICE=hw:0,0
|
AUDIO_DEVICE=hw:0,0
|
||||||
|
|||||||
@@ -136,9 +136,6 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
Media: MediaConfig{
|
Media: MediaConfig{
|
||||||
PublishPort: 9000,
|
PublishPort: 9000,
|
||||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||||
VideoWidth: 1280,
|
|
||||||
VideoHeight: 720,
|
|
||||||
VideoFPS: 30,
|
|
||||||
VideoBitrate: 2000000,
|
VideoBitrate: 2000000,
|
||||||
},
|
},
|
||||||
CameraServo: CameraServoConfig{
|
CameraServo: CameraServoConfig{
|
||||||
@@ -197,15 +194,6 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
||||||
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||||
}
|
}
|
||||||
if cfg.Media.VideoWidth <= 0 {
|
|
||||||
cfg.Media.VideoWidth = 1280
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoHeight <= 0 {
|
|
||||||
cfg.Media.VideoHeight = 720
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoFPS <= 0 {
|
|
||||||
cfg.Media.VideoFPS = 30
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoBitrate <= 0 {
|
if cfg.Media.VideoBitrate <= 0 {
|
||||||
cfg.Media.VideoBitrate = 3000000
|
cfg.Media.VideoBitrate = 3000000
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -16,7 +16,7 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||||
return fmt.Errorf("audio publishUrl missing")
|
return fmt.Errorf("audio publishUrl missing")
|
||||||
}
|
}
|
||||||
if media.VideoWidth <= 0 || media.VideoHeight <= 0 || media.VideoFPS <= 0 || media.VideoBitrate <= 0 {
|
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
||||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||||
@@ -27,9 +27,15 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
if media.VideoWidth > 0 {
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
}
|
||||||
|
if media.VideoHeight > 0 {
|
||||||
|
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
||||||
|
}
|
||||||
|
if media.VideoFPS > 0 {
|
||||||
|
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
||||||
|
}
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||||
audioDevice := audio.CaptureDevice
|
audioDevice := audio.CaptureDevice
|
||||||
if audioDevice == "" || audioDevice == "rovermic" {
|
if audioDevice == "" || audioDevice == "rovermic" {
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ maxWheelSpeed: 350
|
|||||||
media:
|
media:
|
||||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
publishPort: 9000
|
publishPort: 9000
|
||||||
videoWidth: 1280
|
|
||||||
videoHeight: 720
|
|
||||||
videoFps: 30
|
|
||||||
videoBitrate: 2000000
|
videoBitrate: 2000000
|
||||||
manage: true
|
manage: true
|
||||||
service: video-publisher.service
|
service: video-publisher.service
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ roomCameras:
|
|||||||
discord:
|
discord:
|
||||||
token: "DISCORD_BOT_TOKEN"
|
token: "DISCORD_BOT_TOKEN"
|
||||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||||
|
siteUrl: "https://rover.example.com"
|
||||||
channels:
|
channels:
|
||||||
announcements: "123456789012345678"
|
announcements: "123456789012345678"
|
||||||
adminAlerts: "123456789012345678"
|
adminAlerts: "123456789012345678"
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ require('./src/services/roomCameraSocketService');
|
|||||||
require('./src/services/roverSnapshotSocketService');
|
require('./src/services/roverSnapshotSocketService');
|
||||||
require('./src/services/embedHttpService');
|
require('./src/services/embedHttpService');
|
||||||
require('./src/services/logStreamService');
|
require('./src/services/logStreamService');
|
||||||
|
require('./src/services/adminLogService');
|
||||||
require('./src/services/homeAssistantService');
|
require('./src/services/homeAssistantService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
|
require('./src/services/moderationService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
require('./src/services/replaySocketService');
|
require('./src/services/replaySocketService');
|
||||||
require('./src/services/replaySegmentManager');
|
require('./src/services/replaySegmentManager');
|
||||||
|
|||||||
Generated
-1885
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"home-assistant-js-websocket": "^3.1.2",
|
"home-assistant-js-websocket": "^3.1.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"obscenity": "^0.4.6",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BosVsfhJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-DECQ2TrX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-f8xLbmgU.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const morgan = require('morgan');
|
const morgan = require('morgan');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
|
const { parseCookieHeader } = require('../helpers/cookieParser');
|
||||||
|
|
||||||
|
const VISITOR_COOKIE = 'roverd_visitor';
|
||||||
|
const VISITOR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(morgan('dev'));
|
app.use(morgan('dev'));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const cookies = parseCookieHeader(req.headers?.cookie || '');
|
||||||
|
let token = cookies[VISITOR_COOKIE];
|
||||||
|
if (!token) {
|
||||||
|
token = uuidv4();
|
||||||
|
const cookie = `${VISITOR_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${VISITOR_COOKIE_MAX_AGE}`;
|
||||||
|
res.setHeader('Set-Cookie', cookie);
|
||||||
|
}
|
||||||
|
req.visitorToken = token;
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.use(express.static(config.staticDir, { index: false }));
|
app.use(express.static(config.staticDir, { index: false }));
|
||||||
|
|
||||||
const httpServer = http.createServer(app);
|
const httpServer = http.createServer(app);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function parseCookieHeader(header = '') {
|
||||||
|
if (!header || typeof header !== 'string') return {};
|
||||||
|
return header
|
||||||
|
.split(';')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, part) => {
|
||||||
|
const [key, ...rest] = part.split('=');
|
||||||
|
if (!key) return acc;
|
||||||
|
acc[key] = rest.join('=');
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseCookieHeader,
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
function extractForwardedIp(value) {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value) && value.length) {
|
||||||
|
return String(value[0]).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocketIp(socket) {
|
||||||
|
if (!socket) return null;
|
||||||
|
const headers = socket.handshake?.headers || {};
|
||||||
|
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
|
||||||
|
if (forwarded) return forwarded;
|
||||||
|
const realIp = headers['x-real-ip'];
|
||||||
|
if (typeof realIp === 'string' && realIp.trim()) {
|
||||||
|
return realIp.trim();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
socket.handshake?.address ||
|
||||||
|
socket.conn?.remoteAddress ||
|
||||||
|
socket.request?.connection?.remoteAddress ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestIp(req, override) {
|
||||||
|
const fromOverride = extractForwardedIp(override);
|
||||||
|
if (fromOverride) return fromOverride;
|
||||||
|
if (!req) return null;
|
||||||
|
const headers = req.headers || {};
|
||||||
|
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
|
||||||
|
if (forwarded) return forwarded;
|
||||||
|
const realIp = headers['x-real-ip'];
|
||||||
|
if (typeof realIp === 'string' && realIp.trim()) {
|
||||||
|
return realIp.trim();
|
||||||
|
}
|
||||||
|
return req.ip || req.connection?.remoteAddress || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSocketIp,
|
||||||
|
getRequestIp,
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const { getRole, roleEvents } = require('./roleService');
|
||||||
|
const { getSocketIp } = require('../helpers/ipResolver');
|
||||||
|
|
||||||
|
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||||
|
const MAX_HISTORY = 200;
|
||||||
|
const history = [];
|
||||||
|
|
||||||
|
function isAdminRole(role) {
|
||||||
|
return ADMIN_ROLES.has(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminSocket(socket) {
|
||||||
|
return isAdminRole(getRole(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushEntry(entry) {
|
||||||
|
history.push(entry);
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitToAdmins(event, payload) {
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (!isAdminSocket(socket)) return;
|
||||||
|
socket.emit(event, payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAdminEvent({ label, message, ip, meta = null, socketId = null }) {
|
||||||
|
const entry = {
|
||||||
|
id: uuidv4(),
|
||||||
|
ts: Date.now(),
|
||||||
|
label: label || null,
|
||||||
|
message: message || '',
|
||||||
|
ip: ip || null,
|
||||||
|
meta: meta || null,
|
||||||
|
socketId: socketId || null,
|
||||||
|
};
|
||||||
|
pushEntry(entry);
|
||||||
|
emitToAdmins('adminlog:entry', entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateSocket(socket) {
|
||||||
|
if (!socket || !isAdminSocket(socket)) return;
|
||||||
|
socket.emit('adminlog:init', history);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
hydrateSocket(socket);
|
||||||
|
const ip = getSocketIp(socket);
|
||||||
|
if (ip) {
|
||||||
|
logAdminEvent({
|
||||||
|
label: 'socket',
|
||||||
|
message: 'Socket connected',
|
||||||
|
ip,
|
||||||
|
meta: { role: getRole(socket) },
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const disconnectIp = getSocketIp(socket);
|
||||||
|
if (!disconnectIp) return;
|
||||||
|
logAdminEvent({
|
||||||
|
label: 'socket',
|
||||||
|
message: 'Socket disconnected',
|
||||||
|
ip: disconnectIp,
|
||||||
|
meta: { role: getRole(socket) },
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket, role }) => {
|
||||||
|
if (!socket) return;
|
||||||
|
if (!isAdminRole(role)) return;
|
||||||
|
hydrateSocket(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
logAdminEvent,
|
||||||
|
};
|
||||||
@@ -165,7 +165,31 @@ function pickRover() {
|
|||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
candidates.sort((a, b) => a.drivers.size - b.drivers.size);
|
const dockedRank = (rover) => {
|
||||||
|
if (!rover) return 0;
|
||||||
|
if (rover.docked === true) return -1;
|
||||||
|
if (rover.docked === false) return 1;
|
||||||
|
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
|
||||||
|
const docked = sensors?.chargingSources?.homeBase;
|
||||||
|
if (docked === true) return -1;
|
||||||
|
if (docked === false) return 1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aEmpty = idleRank(a);
|
||||||
|
const bEmpty = idleRank(b);
|
||||||
|
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
|
||||||
|
const aDockRank = dockedRank(a);
|
||||||
|
const bDockRank = dockedRank(b);
|
||||||
|
if (aEmpty === 1 && aDockRank !== bDockRank) {
|
||||||
|
return bDockRank - aDockRank;
|
||||||
|
}
|
||||||
|
if (a.drivers.size !== b.drivers.size) {
|
||||||
|
return a.drivers.size - b.drivers.size;
|
||||||
|
}
|
||||||
|
return bDockRank - aDockRank;
|
||||||
|
});
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const logger = require('../globals/logger').child('chatService');
|
const logger = require('../globals/logger').child('chatService');
|
||||||
const { publishEvent, subscribe } = require('./eventBus');
|
const { publishEvent, subscribe } = require('./eventBus');
|
||||||
@@ -7,6 +8,7 @@ const { describeAssignment } = require('./assignmentService');
|
|||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { issueCommand } = require('./commandService');
|
const { issueCommand } = require('./commandService');
|
||||||
|
const { isBannedSocket } = require('./moderationService');
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||||
const RATE_LIMIT_MAX = 5;
|
const RATE_LIMIT_MAX = 5;
|
||||||
@@ -15,9 +17,27 @@ const rateBuckets = new Map(); // socketId -> [timestamps]
|
|||||||
const MAX_HISTORY = 100;
|
const MAX_HISTORY = 100;
|
||||||
const history = [];
|
const history = [];
|
||||||
|
|
||||||
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick', 'faggot', 'fag', 'whore'];
|
// Words in this list are removed from the profanity dataset entirely.
|
||||||
|
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
|
||||||
|
const normalizedProfanityAllowlist = new Set(PROFANITY_ALLOWLIST
|
||||||
|
.filter((term) => typeof term === 'string')
|
||||||
|
.map((term) => term.trim().toLowerCase())
|
||||||
|
.filter(Boolean));
|
||||||
|
const profanityDataset = new DataSet()
|
||||||
|
.addAll(englishDataset)
|
||||||
|
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
|
||||||
|
.build();
|
||||||
|
const profanityMatcher = new RegExpMatcher({
|
||||||
|
...profanityDataset,
|
||||||
|
...englishRecommendedTransformers,
|
||||||
|
whitelistedTerms: profanityDataset.whitelistedTerms,
|
||||||
|
});
|
||||||
const DUPLICATE_WINDOW_MS = 15000;
|
const DUPLICATE_WINDOW_MS = 15000;
|
||||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
||||||
|
const typingBySocket = new Map(); // socketId -> boolean
|
||||||
|
const TYPING_START_NOTE = 72;
|
||||||
|
const TYPING_SEND_NOTE = 79;
|
||||||
|
const TYPING_NOTE_DURATION = 8;
|
||||||
|
|
||||||
function withinRateLimit(socketId) {
|
function withinRateLimit(socketId) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -29,8 +49,8 @@ function withinRateLimit(socketId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasProfanity(text) {
|
function hasProfanity(text) {
|
||||||
const lower = text.toLowerCase();
|
if (typeof text !== 'string' || !text) return false;
|
||||||
return PROFANITY_LIST.some((word) => lower.includes(word));
|
return profanityMatcher.hasMatch(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDuplicate(socketId, text) {
|
function isDuplicate(socketId, text) {
|
||||||
@@ -86,6 +106,50 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTypingPayload(socket, meta = {}) {
|
||||||
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||||
|
const socketId = socket?.id || null;
|
||||||
|
const fromDiscord = Boolean(meta.fromDiscord);
|
||||||
|
let typingId = meta.typingId || null;
|
||||||
|
if (!typingId) {
|
||||||
|
if (fromDiscord) {
|
||||||
|
if (meta.discordUserId) {
|
||||||
|
typingId = `discord:${meta.discordUserId}`;
|
||||||
|
} else if (meta.discordUserName) {
|
||||||
|
typingId = `discord:${meta.discordUserName}`;
|
||||||
|
} else if (meta.nickname) {
|
||||||
|
typingId = `discord:${meta.nickname}`;
|
||||||
|
} else {
|
||||||
|
typingId = 'discord:unknown';
|
||||||
|
}
|
||||||
|
} else if (socketId) {
|
||||||
|
typingId = `socket:${socketId}`;
|
||||||
|
} else if (meta.nickname) {
|
||||||
|
typingId = `socket:${meta.nickname}`;
|
||||||
|
} else {
|
||||||
|
typingId = 'socket:unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: uuidv4(),
|
||||||
|
ts: Date.now(),
|
||||||
|
typingId,
|
||||||
|
isTyping: Boolean(meta.isTyping),
|
||||||
|
socketId,
|
||||||
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
|
role: meta.role || getRole(socket),
|
||||||
|
roverId,
|
||||||
|
fromDiscord,
|
||||||
|
discordGuildId: meta.discordGuildId || null,
|
||||||
|
discordGuildName: meta.discordGuildName || null,
|
||||||
|
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||||
|
discordChannelId: meta.discordChannelId || null,
|
||||||
|
discordUserId: meta.discordUserId || null,
|
||||||
|
discordUserName: meta.discordUserName || null,
|
||||||
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pushHistory(message) {
|
function pushHistory(message) {
|
||||||
history.push(message);
|
history.push(message);
|
||||||
if (history.length > MAX_HISTORY) {
|
if (history.length > MAX_HISTORY) {
|
||||||
@@ -98,6 +162,27 @@ function broadcastMessage(message) {
|
|||||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function broadcastTyping(payload) {
|
||||||
|
publishEvent({ source: 'chat', type: 'chat:typing', payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTypingNote(roverId, note, socketId) {
|
||||||
|
if (!roverId) return;
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, {
|
||||||
|
type: 'song',
|
||||||
|
song: {
|
||||||
|
notes: [{ note, duration: TYPING_NOTE_DURATION }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||||
|
log('Typing tone sent', { roverId, note, socketId });
|
||||||
|
} catch (err) {
|
||||||
|
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||||
|
log('Typing tone failed', { roverId, note, socketId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTtsOptions(raw = {}) {
|
function normalizeTtsOptions(raw = {}) {
|
||||||
if (!raw || typeof raw !== 'object') return null;
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
const speak = raw.speak !== false;
|
const speak = raw.speak !== false;
|
||||||
@@ -147,6 +232,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
|||||||
const ttsOptions = normalizeTtsOptions(tts);
|
const ttsOptions = normalizeTtsOptions(tts);
|
||||||
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
||||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
|
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
maybeSpeak(socket, message, ttsOptions);
|
maybeSpeak(socket, message, ttsOptions);
|
||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
@@ -158,7 +244,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
const audio = record?.meta?.audio || {};
|
const audio = record?.meta?.audio || {};
|
||||||
const ttsEnabled = Boolean(audio.ttsEnabled);
|
const ttsEnabled = Boolean(audio.ttsEnabled);
|
||||||
if (!ttsEnabled) return;
|
if (!ttsEnabled) return;
|
||||||
// if (!roverManager.canDrive(message.roverId, socket)) return;
|
if (!roverManager.canDrive(message.roverId, socket)) return;
|
||||||
try {
|
try {
|
||||||
issueCommand(message.roverId, {
|
issueCommand(message.roverId, {
|
||||||
type: 'tts',
|
type: 'tts',
|
||||||
@@ -218,17 +304,88 @@ function sendExternalMessage({
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendExternalTyping({
|
||||||
|
nickname = 'Discord',
|
||||||
|
role = 'user',
|
||||||
|
roverId = null,
|
||||||
|
discordGuildId = null,
|
||||||
|
discordGuildName = null,
|
||||||
|
discordGuildIconUrl = null,
|
||||||
|
discordChannelId = null,
|
||||||
|
discordUserId = null,
|
||||||
|
discordUserName = null,
|
||||||
|
discordUserAvatarUrl = null,
|
||||||
|
isTyping = true,
|
||||||
|
}) {
|
||||||
|
const payload = buildTypingPayload(null, {
|
||||||
|
nickname,
|
||||||
|
role,
|
||||||
|
roverId,
|
||||||
|
fromDiscord: true,
|
||||||
|
discordGuildId,
|
||||||
|
discordGuildName,
|
||||||
|
discordGuildIconUrl,
|
||||||
|
discordChannelId,
|
||||||
|
discordUserId,
|
||||||
|
discordUserName,
|
||||||
|
discordUserAvatarUrl,
|
||||||
|
isTyping,
|
||||||
|
});
|
||||||
|
broadcastTyping(payload);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.emit('chat:init', history);
|
if (!isBannedSocket(socket)) {
|
||||||
|
socket.emit('chat:init', history);
|
||||||
|
}
|
||||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||||
|
socket.on('chat:typing', (payload = {}) => {
|
||||||
|
const isTyping = Boolean(payload?.isTyping);
|
||||||
|
const wasTyping = typingBySocket.get(socket.id);
|
||||||
|
if (isTyping) {
|
||||||
|
typingBySocket.set(socket.id, true);
|
||||||
|
if (!wasTyping) {
|
||||||
|
const roverId = resolveRoverId(socket?.id);
|
||||||
|
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
typingBySocket.delete(socket.id);
|
||||||
|
}
|
||||||
|
const roverId = resolveRoverId(socket?.id);
|
||||||
|
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
|
||||||
|
broadcastTyping(typingPayload);
|
||||||
|
});
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
if (!typingBySocket.has(socket.id)) return;
|
||||||
|
typingBySocket.delete(socket.id);
|
||||||
|
const roverId = resolveRoverId(socket?.id);
|
||||||
|
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
||||||
|
broadcastTyping(typingPayload);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribe('chat:message', ({ payload }) => {
|
subscribe('chat:message', ({ payload }) => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
io.emit('chat:message', payload);
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (!isBannedSocket(socket)) {
|
||||||
|
socket.emit('chat:message', payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
subscribe('chat:typing', ({ payload }) => {
|
||||||
|
if (!payload) return;
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (!isBannedSocket(socket)) {
|
||||||
|
socket.emit('chat:typing', payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleIncoming,
|
handleIncoming,
|
||||||
sendExternalMessage,
|
sendExternalMessage,
|
||||||
|
sendExternalTyping,
|
||||||
|
buildTypingPayload,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,10 +64,11 @@ io.on('connection', (socket) => {
|
|||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
throw new Error('roverId required');
|
throw new Error('roverId required');
|
||||||
}
|
}
|
||||||
if (!roverManager.canDrive(roverId, socket)) {
|
const payload = data ? { ...data } : {};
|
||||||
|
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||||
|
if (!isSongCommand && !roverManager.canDrive(roverId, socket)) {
|
||||||
throw new Error('Not your turn or no control');
|
throw new Error('Not your turn or no control');
|
||||||
}
|
}
|
||||||
const payload = data ? { ...data } : {};
|
|
||||||
const isAdminSocket = isAdmin(socket);
|
const isAdminSocket = isAdmin(socket);
|
||||||
const driveDirect = payload?.driveDirect;
|
const driveDirect = payload?.driveDirect;
|
||||||
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||||
@@ -109,3 +110,20 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('command', handleCommand);
|
socket.on('command', handleCommand);
|
||||||
socket.on('command:issue', handleCommand);
|
socket.on('command:issue', handleCommand);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isSongRawPayload(payload) {
|
||||||
|
if (!payload) return false;
|
||||||
|
const raw = payload.raw;
|
||||||
|
if (!raw) return false;
|
||||||
|
let bytes = null;
|
||||||
|
if (Buffer.isBuffer(raw)) {
|
||||||
|
bytes = raw;
|
||||||
|
} else if (Array.isArray(raw)) {
|
||||||
|
bytes = Buffer.from(raw);
|
||||||
|
} else if (typeof raw === 'string') {
|
||||||
|
bytes = Buffer.from(raw, 'base64');
|
||||||
|
}
|
||||||
|
if (!bytes || bytes.length === 0) return false;
|
||||||
|
const opcode = bytes[0];
|
||||||
|
return opcode === 140 || opcode === 141;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ const { loadConfig } = require('../helpers/configLoader');
|
|||||||
const { subscribe } = require('./eventBus');
|
const { subscribe } = require('./eventBus');
|
||||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||||
const { MODES, getMode, setMode } = require('./modeManager');
|
const { MODES, getMode, setMode } = require('./modeManager');
|
||||||
const { sendExternalMessage } = require('./chatService');
|
const { sendExternalMessage, sendExternalTyping } = require('./chatService');
|
||||||
const { buildReplayVideo } = require('./replayBuildService');
|
const { buildReplayVideo } = require('./replayBuildService');
|
||||||
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
|
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
|
||||||
const { getActiveDrivers } = require('./turnService');
|
const { getActiveDrivers } = require('./turnService');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { tryTriggerReplay } = require('./replayService');
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
|
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
|
||||||
|
const { getModerationSnapshot, applyBan, applyUnban } = require('./moderationService');
|
||||||
const {
|
const {
|
||||||
getGuildConfig,
|
getGuildConfig,
|
||||||
listGuildConfigs,
|
listGuildConfigs,
|
||||||
@@ -45,6 +46,7 @@ if (!enabled) {
|
|||||||
const intents = [
|
const intents = [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.GuildMessageTyping,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ const client = new Client({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const channelCache = new Map();
|
const channelCache = new Map();
|
||||||
|
const typingMessageCache = new Map();
|
||||||
let skippedFirstModeAnnouncement = false;
|
let skippedFirstModeAnnouncement = false;
|
||||||
const PRESENCE_ROTATE_MS = 20000;
|
const PRESENCE_ROTATE_MS = 20000;
|
||||||
let presenceInterval = null;
|
let presenceInterval = null;
|
||||||
@@ -256,10 +259,138 @@ function formatHelp() {
|
|||||||
'`rs unlock <id>` — unlock a rover',
|
'`rs unlock <id>` — unlock a rover',
|
||||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||||
'`rs goal [text|clear]` — show or set community goal',
|
'`rs goal [text|clear]` — show or set community goal',
|
||||||
|
'`rs ban <id> [reason]` — ban a user',
|
||||||
|
'`rs timeout <id> <duration> [reason]` — timeout a user',
|
||||||
|
'`rs unban <id>` — remove a ban/timeout',
|
||||||
|
'`rs users` — list recent users',
|
||||||
|
'`rs bans` — list active bans/timeouts',
|
||||||
'`ts` — show time status',
|
'`ts` — show time status',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDuration(input) {
|
||||||
|
if (!input) return null;
|
||||||
|
const match = String(input).trim().match(/^(\d+)(s|m|h|d)?$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
const value = Number(match[1]);
|
||||||
|
const unit = (match[2] || 'm').toLowerCase();
|
||||||
|
const multipliers = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
|
||||||
|
const ms = value * (multipliers[unit] || 0);
|
||||||
|
return Number.isFinite(ms) && ms > 0 ? ms : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModerationDuration(ms) {
|
||||||
|
if (!ms) return 'permanent';
|
||||||
|
const seconds = Math.ceil(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.ceil(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.ceil(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
const days = Math.ceil(hours / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBanCommand(message, tokens) {
|
||||||
|
const target = tokens.shift();
|
||||||
|
if (!target) {
|
||||||
|
await message.reply('Usage: `rs ban <id> [reason]`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = tokens.join(' ').trim() || null;
|
||||||
|
try {
|
||||||
|
const ban = applyBan(target, {
|
||||||
|
reason,
|
||||||
|
createdBy: `discord:${message.author.id}`,
|
||||||
|
});
|
||||||
|
await message.reply(
|
||||||
|
`Banned **${sanitizeMentions(target)}**${reason ? ` — ${sanitizeMentions(reason)}` : ''} (id: ${ban.id}).`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await message.reply(`Ban failed: ${sanitizeMentions(err.message)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTimeoutCommand(message, tokens) {
|
||||||
|
const target = tokens.shift();
|
||||||
|
const durationRaw = tokens.shift();
|
||||||
|
if (!target || !durationRaw) {
|
||||||
|
await message.reply('Usage: `rs timeout <id> <duration> [reason]`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const durationMs = parseDuration(durationRaw);
|
||||||
|
if (!durationMs) {
|
||||||
|
await message.reply('Invalid duration. Use formats like `30m`, `2h`, or `1d`.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = tokens.join(' ').trim() || null;
|
||||||
|
try {
|
||||||
|
const ban = applyBan(target, {
|
||||||
|
durationMs,
|
||||||
|
reason,
|
||||||
|
createdBy: `discord:${message.author.id}`,
|
||||||
|
});
|
||||||
|
await message.reply(
|
||||||
|
`Timed out **${sanitizeMentions(target)}** for ${formatModerationDuration(durationMs)}${reason ? ` — ${sanitizeMentions(reason)}` : ''} (id: ${ban.id}).`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await message.reply(`Timeout failed: ${sanitizeMentions(err.message)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnbanCommand(message, tokens) {
|
||||||
|
const target = tokens.shift();
|
||||||
|
if (!target) {
|
||||||
|
await message.reply('Usage: `rs unban <id>`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const removed = applyUnban(target);
|
||||||
|
if (removed) {
|
||||||
|
await message.reply(`Unbanned **${sanitizeMentions(target)}**.`);
|
||||||
|
} else {
|
||||||
|
await message.reply(`No ban found for **${sanitizeMentions(target)}**.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await message.reply(`Unban failed: ${sanitizeMentions(err.message)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUsersCommand(message) {
|
||||||
|
const snapshot = getModerationSnapshot();
|
||||||
|
const users = snapshot.users || [];
|
||||||
|
const recent = users
|
||||||
|
.filter((user) => user.lastSeen)
|
||||||
|
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
|
||||||
|
.slice(0, 10);
|
||||||
|
if (!recent.length) {
|
||||||
|
await message.reply('No recent users.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = recent.map((user) => {
|
||||||
|
const name = user.nicknames?.[user.nicknames.length - 1] || user.id.slice(0, 6);
|
||||||
|
const status = user.ban ? 'BANNED' : 'ok';
|
||||||
|
return `• ${sanitizeMentions(name)} (${user.id.slice(0, 6)}) — ${status}`;
|
||||||
|
});
|
||||||
|
await message.reply(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBansCommand(message) {
|
||||||
|
const snapshot = getModerationSnapshot();
|
||||||
|
const bans = snapshot.bans || [];
|
||||||
|
if (!bans.length) {
|
||||||
|
await message.reply('No active bans/timeouts.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = bans.slice(0, 10).map((ban) => {
|
||||||
|
const expiresIn = ban.expiresAt ? Math.max(0, ban.expiresAt - Date.now()) : null;
|
||||||
|
return `• ${ban.id.slice(0, 6)} ${ban.userId ? `user ${ban.userId.slice(0, 6)}` : 'target'} — ${
|
||||||
|
expiresIn ? `timeout ${formatModerationDuration(expiresIn)}` : 'ban'
|
||||||
|
}`;
|
||||||
|
});
|
||||||
|
await message.reply(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
function findRoverRecord(id) {
|
function findRoverRecord(id) {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
for (const record of rovers.values()) {
|
for (const record of rovers.values()) {
|
||||||
@@ -693,7 +824,12 @@ async function handleCommand(message) {
|
|||||||
action !== 'help' &&
|
action !== 'help' &&
|
||||||
action !== 'replay' &&
|
action !== 'replay' &&
|
||||||
action !== 'bridge' &&
|
action !== 'bridge' &&
|
||||||
action !== 'goal'
|
action !== 'goal' &&
|
||||||
|
action !== 'ban' &&
|
||||||
|
action !== 'timeout' &&
|
||||||
|
action !== 'unban' &&
|
||||||
|
action !== 'users' &&
|
||||||
|
action !== 'bans'
|
||||||
) {
|
) {
|
||||||
return; // ignore non-admins for privileged commands
|
return; // ignore non-admins for privileged commands
|
||||||
}
|
}
|
||||||
@@ -726,6 +862,21 @@ async function handleCommand(message) {
|
|||||||
case 'goal':
|
case 'goal':
|
||||||
await handleGoalCommand(message, tokens);
|
await handleGoalCommand(message, tokens);
|
||||||
break;
|
break;
|
||||||
|
case 'ban':
|
||||||
|
await handleBanCommand(message, tokens);
|
||||||
|
break;
|
||||||
|
case 'timeout':
|
||||||
|
await handleTimeoutCommand(message, tokens);
|
||||||
|
break;
|
||||||
|
case 'unban':
|
||||||
|
await handleUnbanCommand(message, tokens);
|
||||||
|
break;
|
||||||
|
case 'users':
|
||||||
|
await handleUsersCommand(message);
|
||||||
|
break;
|
||||||
|
case 'bans':
|
||||||
|
await handleBansCommand(message);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
await message.reply(formatHelp());
|
await message.reply(formatHelp());
|
||||||
break;
|
break;
|
||||||
@@ -755,6 +906,60 @@ function formatWebhookUsername(payload) {
|
|||||||
return suffix ? `${name} · ${suffix}` : name;
|
return suffix ? `${name} · ${suffix}` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTypingId(payload = {}) {
|
||||||
|
if (payload.typingId) return payload.typingId;
|
||||||
|
if (payload.fromDiscord) {
|
||||||
|
if (payload.discordUserId) return `discord:${payload.discordUserId}`;
|
||||||
|
if (payload.discordUserName) return `discord:${payload.discordUserName}`;
|
||||||
|
if (payload.nickname) return `discord:${payload.nickname}`;
|
||||||
|
return 'discord:unknown';
|
||||||
|
}
|
||||||
|
if (payload.socketId) return `socket:${payload.socketId}`;
|
||||||
|
if (payload.nickname) return `socket:${payload.nickname}`;
|
||||||
|
return 'socket:unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function typingCacheKey(guildId, typingId) {
|
||||||
|
return `${guildId}:${typingId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTypingMessage(guildId, typingId) {
|
||||||
|
const key = typingCacheKey(guildId, typingId);
|
||||||
|
const record = typingMessageCache.get(key);
|
||||||
|
if (!record) return;
|
||||||
|
typingMessageCache.delete(key);
|
||||||
|
if (record.timeoutId) clearTimeout(record.timeoutId);
|
||||||
|
const channel = await fetchChannel(record.channelId);
|
||||||
|
if (!channel?.messages?.fetch) return;
|
||||||
|
try {
|
||||||
|
const msg = await channel.messages.fetch(record.messageId);
|
||||||
|
await msg.delete();
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.code !== 10008) {
|
||||||
|
logger.warn('Failed to delete typing message', { guildId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTypingMessage(entry, payload) {
|
||||||
|
const typingId = getTypingId(payload);
|
||||||
|
const key = typingCacheKey(entry.guildId, typingId);
|
||||||
|
if (typingMessageCache.has(key)) return;
|
||||||
|
const channel = await fetchChannel(entry.channelId);
|
||||||
|
if (!channel?.send) return;
|
||||||
|
const username = formatWebhookUsername(payload);
|
||||||
|
const content = `-# *${username} is typing...*`;
|
||||||
|
try {
|
||||||
|
const message = await channel.send({ content, allowedMentions: { parse: [] } });
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
clearTypingMessage(entry.guildId, typingId);
|
||||||
|
}, 20000);
|
||||||
|
typingMessageCache.set(key, { channelId: entry.channelId, messageId: message.id, timeoutId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to send typing message', { guildId: entry.guildId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBridgeInbound(message) {
|
async function handleBridgeInbound(message) {
|
||||||
if (!message.guild) return;
|
if (!message.guild) return;
|
||||||
const guildConfig = getGuildConfig(message.guild.id);
|
const guildConfig = getGuildConfig(message.guild.id);
|
||||||
@@ -788,9 +993,15 @@ async function handleBridgeInbound(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmbed({ title, description, color }) {
|
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||||
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
||||||
if (description) embed.setDescription(description);
|
const siteUrl =
|
||||||
|
includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : '';
|
||||||
|
if (description) {
|
||||||
|
embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description);
|
||||||
|
} else if (siteUrl) {
|
||||||
|
embed.setDescription(siteUrl);
|
||||||
|
}
|
||||||
embed.setTimestamp(new Date());
|
embed.setTimestamp(new Date());
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
@@ -900,6 +1111,41 @@ function buildBatteryStatusEmbed(color, records = null) {
|
|||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAllUnlockedEmbed(color, records = null) {
|
||||||
|
const embed = buildEmbed({ title: 'All Rovers Unlocked', color: color || 0x4caf50 });
|
||||||
|
const baseRecords = records || Array.from(rovers.values());
|
||||||
|
const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean);
|
||||||
|
if (snapshots.length === 0) {
|
||||||
|
embed.setDescription('No rovers online.');
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
snapshots.forEach((snapshot) => {
|
||||||
|
const percent = snapshot?.batteryState?.percentDisplay;
|
||||||
|
const percentLabel = percent != null ? `${percent}%` : 'n/a';
|
||||||
|
embed.addFields({
|
||||||
|
name: snapshot.name,
|
||||||
|
value: `Battery: ${percentLabel}`,
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAllUnlockedCaption(records = null) {
|
||||||
|
return 'All rovers unlocked.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccessModeEmbed(mode, color) {
|
||||||
|
const total = rovers.size;
|
||||||
|
const unlocked = Array.from(rovers.values()).filter((entry) => !entry.locked).length;
|
||||||
|
const embed = buildEmbed({
|
||||||
|
title: 'Access Mode Updated',
|
||||||
|
description: `Access mode set to **${mode}**\nUnlocked rovers: **${unlocked}/${total}**`,
|
||||||
|
color: color || 0x2196f3,
|
||||||
|
});
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
function buildBatteryCaption(type, payload) {
|
function buildBatteryCaption(type, payload) {
|
||||||
const roverId = payload?.roverId || 'unknown';
|
const roverId = payload?.roverId || 'unknown';
|
||||||
const record = rovers.get(roverId) || findRoverRecord(roverId);
|
const record = rovers.get(roverId) || findRoverRecord(roverId);
|
||||||
@@ -936,23 +1182,56 @@ function buildBatteryCaption(type, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function announce({ channelId, content, pingRoleId, color, title, description, embeds }) {
|
async function announce({
|
||||||
|
channelId,
|
||||||
|
content,
|
||||||
|
pingRoleId,
|
||||||
|
prefixMentions = true,
|
||||||
|
includeSiteUrl = true,
|
||||||
|
color,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
embeds,
|
||||||
|
}) {
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
const prefix = pingRoleId ? `<@&${pingRoleId}> ` : '';
|
const mentionChunks = [];
|
||||||
|
if (pingRoleId) mentionChunks.push(`<@&${pingRoleId}>`);
|
||||||
|
const prefix = prefixMentions && mentionChunks.length ? `${mentionChunks.join(' ')} ` : '';
|
||||||
const payloadEmbeds =
|
const payloadEmbeds =
|
||||||
Array.isArray(embeds) && embeds.length > 0
|
Array.isArray(embeds) && embeds.length > 0
|
||||||
? embeds
|
? embeds
|
||||||
: [buildEmbed({ title, description, color })];
|
: [buildEmbed({ title, description, color, includeSiteUrl })];
|
||||||
const allowedMentions = pingRoleId ? { roles: [pingRoleId], parse: [] } : { parse: [] };
|
const allowedMentions = {
|
||||||
|
parse: [],
|
||||||
|
roles: pingRoleId ? [pingRoleId] : [],
|
||||||
|
};
|
||||||
await sendToChannel(
|
await sendToChannel(
|
||||||
channelId,
|
channelId,
|
||||||
`${prefix}${content || ''}`.trim(),
|
`${prefix}${content || ''}`.trim(),
|
||||||
{ embeds: payloadEmbeds },
|
{ embeds: payloadEmbeds },
|
||||||
allowedMentions,
|
allowedMentions,
|
||||||
!pingRoleId, // keep role mention intact when pinging
|
!pingRoleId, // keep mention intact when pinging
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function announceUserStatus({ channelId, content, color, title, description, embeds }) {
|
||||||
|
const roles = discordConfig.roles || {};
|
||||||
|
const mode = getMode();
|
||||||
|
const allowPing = mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN;
|
||||||
|
const pingRoleId = allowPing ? roles.announcementPing || null : null;
|
||||||
|
const mainLine = pingRoleId ? `<@&${pingRoleId}> ${content}`.trim() : content;
|
||||||
|
await announce({
|
||||||
|
channelId,
|
||||||
|
content: mainLine,
|
||||||
|
pingRoleId,
|
||||||
|
prefixMentions: false,
|
||||||
|
color,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
embeds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleBusEvent(event) {
|
function handleBusEvent(event) {
|
||||||
const { type, payload } = event || {};
|
const { type, payload } = event || {};
|
||||||
const channels = discordConfig.channels || {};
|
const channels = discordConfig.channels || {};
|
||||||
@@ -964,19 +1243,23 @@ function handleBusEvent(event) {
|
|||||||
schedulePresenceRotation();
|
schedulePresenceRotation();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
announce({
|
if (payload?.mode === MODES.OPEN || payload?.mode === MODES.TURNS) {
|
||||||
channelId: channels.announcements,
|
const caption = `Access mode set to ${payload?.mode}.`;
|
||||||
pingRoleId: roles.announcementPing || null,
|
announceUserStatus({
|
||||||
color: 0x2196f3,
|
channelId: channels.announcements,
|
||||||
title: 'Mode Changed',
|
content: caption,
|
||||||
description: `Server mode set to **${payload?.mode}**`,
|
color: 0x2196f3,
|
||||||
});
|
embeds: [buildAccessModeEmbed(payload?.mode, 0x2196f3)],
|
||||||
|
});
|
||||||
|
}
|
||||||
schedulePresenceRotation();
|
schedulePresenceRotation();
|
||||||
break;
|
break;
|
||||||
case 'communityGoal.updated': {
|
case 'communityGoal.updated': {
|
||||||
const goalText = payload?.text ? sanitizeMentions(String(payload.text)) : null;
|
const goalText = payload?.text ? sanitizeMentions(String(payload.text)) : null;
|
||||||
announce({
|
const caption = goalText ? `Community goal: ${goalText}` : 'Community goal cleared.';
|
||||||
|
announceUserStatus({
|
||||||
channelId: channels.announcements,
|
channelId: channels.announcements,
|
||||||
|
content: caption,
|
||||||
color: 0x8bc34a,
|
color: 0x8bc34a,
|
||||||
title: 'Community Goal',
|
title: 'Community Goal',
|
||||||
description: goalText ? goalText : 'Community goal cleared.',
|
description: goalText ? goalText : 'Community goal cleared.',
|
||||||
@@ -990,16 +1273,19 @@ function handleBusEvent(event) {
|
|||||||
color: 0xf0b651,
|
color: 0xf0b651,
|
||||||
title: 'Rover Locked',
|
title: 'Rover Locked',
|
||||||
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
|
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
|
||||||
|
includeSiteUrl: false,
|
||||||
});
|
});
|
||||||
schedulePresenceRotation();
|
schedulePresenceRotation();
|
||||||
break;
|
break;
|
||||||
case 'rover.unlocked':
|
case 'rover.unlocked':
|
||||||
announce({
|
schedulePresenceRotation();
|
||||||
|
break;
|
||||||
|
case 'rovers.allUnlocked':
|
||||||
|
announceUserStatus({
|
||||||
channelId: channels.announcements,
|
channelId: channels.announcements,
|
||||||
pingRoleId: roles.announcementPing || null,
|
content: buildAllUnlockedCaption(),
|
||||||
color: 0x4caf50,
|
color: 0x4caf50,
|
||||||
title: 'Rover Unlocked',
|
embeds: [buildAllUnlockedEmbed(0x4caf50)],
|
||||||
description: `${payload?.roverId} unlocked.`,
|
|
||||||
});
|
});
|
||||||
schedulePresenceRotation();
|
schedulePresenceRotation();
|
||||||
break;
|
break;
|
||||||
@@ -1137,6 +1423,7 @@ function handleChatBridgeOutbound(event) {
|
|||||||
const avatarURL = payload.fromDiscord
|
const avatarURL = payload.fromDiscord
|
||||||
? payload.discordUserAvatarUrl || null
|
? payload.discordUserAvatarUrl || null
|
||||||
: client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null;
|
: client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null;
|
||||||
|
const typingId = getTypingId(payload);
|
||||||
guildConfigs.forEach((entry) => {
|
guildConfigs.forEach((entry) => {
|
||||||
if (!entry?.channelId || !entry?.webhookId || !entry?.webhookToken) return;
|
if (!entry?.channelId || !entry?.webhookId || !entry?.webhookToken) return;
|
||||||
if (payload.fromDiscord) {
|
if (payload.fromDiscord) {
|
||||||
@@ -1153,12 +1440,61 @@ function handleChatBridgeOutbound(event) {
|
|||||||
avatarURL,
|
avatarURL,
|
||||||
allowedMentions: { parse: [] },
|
allowedMentions: { parse: [] },
|
||||||
})
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (!payload.fromDiscord) {
|
||||||
|
clearTypingMessage(entry.guildId, typingId);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logger.warn('Failed to send webhook message', { guildId: entry.guildId, error: err.message });
|
logger.warn('Failed to send webhook message', { guildId: entry.guildId, error: err.message });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleChatTypingOutbound(event) {
|
||||||
|
const payload = event?.payload;
|
||||||
|
if (!payload || payload.fromDiscord) return;
|
||||||
|
const guildConfigs = listGuildConfigs();
|
||||||
|
if (!guildConfigs.length) return;
|
||||||
|
guildConfigs.forEach((entry) => {
|
||||||
|
if (!entry?.channelId) return;
|
||||||
|
if (payload.isTyping) {
|
||||||
|
sendTypingMessage(entry, payload);
|
||||||
|
} else {
|
||||||
|
clearTypingMessage(entry.guildId, getTypingId(payload));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDiscordTypingStart(typing) {
|
||||||
|
const channelId = typing?.channelId || typing?.channel?.id || null;
|
||||||
|
const guildId = typing?.guild?.id || typing?.channel?.guild?.id || null;
|
||||||
|
if (!guildId || !channelId) return;
|
||||||
|
const guildConfig = getGuildConfig(guildId);
|
||||||
|
if (!guildConfig?.channelId) return;
|
||||||
|
if (String(channelId) !== String(guildConfig.channelId)) return;
|
||||||
|
const user = typing?.user || null;
|
||||||
|
if (user?.bot) return;
|
||||||
|
const member = typing?.member || null;
|
||||||
|
const nickname = member?.nickname || user?.globalName || user?.username || 'Discord';
|
||||||
|
const role = isAdminUser(user?.id) ? 'admin' : 'user';
|
||||||
|
const guildIconUrl = typing?.guild?.iconURL?.({ extension: 'png', size: 64 }) || null;
|
||||||
|
const userAvatarUrl = user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null;
|
||||||
|
sendExternalTyping({
|
||||||
|
nickname,
|
||||||
|
role,
|
||||||
|
roverId: null,
|
||||||
|
discordGuildId: guildId,
|
||||||
|
discordGuildName: typing?.guild?.name || null,
|
||||||
|
discordGuildIconUrl: guildIconUrl,
|
||||||
|
discordChannelId: channelId,
|
||||||
|
discordUserId: user?.id || null,
|
||||||
|
discordUserName: user?.globalName || user?.username || null,
|
||||||
|
discordUserAvatarUrl: userAvatarUrl,
|
||||||
|
isTyping: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
client.on('messageCreate', async (message) => {
|
client.on('messageCreate', async (message) => {
|
||||||
try {
|
try {
|
||||||
await handleCommand(message);
|
await handleCommand(message);
|
||||||
@@ -1168,6 +1504,12 @@ client.on('messageCreate', async (message) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
client.on('typingStart', (typing) => {
|
||||||
|
handleDiscordTypingStart(typing).catch((err) => {
|
||||||
|
logger.warn('Error handling Discord typing', err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
client.once('ready', () => {
|
client.once('ready', () => {
|
||||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||||
schedulePresenceRotation();
|
schedulePresenceRotation();
|
||||||
@@ -1175,6 +1517,7 @@ client.once('ready', () => {
|
|||||||
|
|
||||||
subscribe('*', handleBusEvent);
|
subscribe('*', handleBusEvent);
|
||||||
subscribe('chat:message', handleChatBridgeOutbound);
|
subscribe('chat:message', handleChatBridgeOutbound);
|
||||||
|
subscribe('chat:typing', handleChatTypingOutbound);
|
||||||
|
|
||||||
client.login(discordConfig.token).catch((err) => {
|
client.login(discordConfig.token).catch((err) => {
|
||||||
logger.error('Discord login failed', err.message);
|
logger.error('Discord login failed', err.message);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid');
|
|||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const loggerRoot = require('../globals/logger');
|
const loggerRoot = require('../globals/logger');
|
||||||
const logger = loggerRoot.child('logStream');
|
const logger = loggerRoot.child('logStream');
|
||||||
|
const { isBannedSocket } = require('./moderationService');
|
||||||
|
|
||||||
const MAX_HISTORY = 200;
|
const MAX_HISTORY = 200;
|
||||||
const history = [];
|
const history = [];
|
||||||
@@ -14,11 +15,16 @@ function pushEntry(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function broadcast(entry) {
|
function broadcast(entry) {
|
||||||
io.emit('log:entry', entry);
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (!isBannedSocket(socket)) {
|
||||||
|
socket.emit('log:entry', entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateSocket(socket) {
|
function hydrateSocket(socket) {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
if (isBannedSocket(socket)) return;
|
||||||
socket.emit('log:init', history);
|
socket.emit('log:init', history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('moderationService');
|
||||||
|
const { getRole, roleEvents } = require('./roleService');
|
||||||
|
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||||
|
const { getSocketIp } = require('../helpers/ipResolver');
|
||||||
|
const { parseCookieHeader } = require('../helpers/cookieParser');
|
||||||
|
const { logAdminEvent } = require('./adminLogService');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||||
|
const STORE_PATH = path.join(DATA_DIR, 'moderation.json');
|
||||||
|
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||||
|
const VISITOR_COOKIE = 'roverd_visitor';
|
||||||
|
const EVENT_ALLOWLIST = new Set(['auth:login']);
|
||||||
|
const MAX_HISTORY_ENTRIES = 50;
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
function loadStore() {
|
||||||
|
if (cache) return cache;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||||
|
cache = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
logger.warn('Failed to load moderation store', err.message);
|
||||||
|
}
|
||||||
|
cache = { users: {}, bans: {}, history: [] };
|
||||||
|
}
|
||||||
|
if (!cache.users) cache.users = {};
|
||||||
|
if (!cache.bans) cache.bans = {};
|
||||||
|
if (!Array.isArray(cache.history)) cache.history = [];
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminRole(role) {
|
||||||
|
return ADMIN_ROLES.has(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminSocket(socket) {
|
||||||
|
return isAdminRole(getRole(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStore(next) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||||
|
cache = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordHistory(entry) {
|
||||||
|
const store = loadStore();
|
||||||
|
store.history.push(entry);
|
||||||
|
if (store.history.length > MAX_HISTORY_ENTRIES) {
|
||||||
|
store.history.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClientId(socket) {
|
||||||
|
const auth = socket.handshake?.auth || {};
|
||||||
|
const clientId =
|
||||||
|
auth.clientId ||
|
||||||
|
socket.handshake?.query?.clientId ||
|
||||||
|
socket.data?.clientId ||
|
||||||
|
null;
|
||||||
|
if (typeof clientId === 'string' && clientId.trim()) {
|
||||||
|
return clientId.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVisitorToken(socket) {
|
||||||
|
const cookies = parseCookieHeader(socket.handshake?.headers?.cookie || '');
|
||||||
|
const token = cookies[VISITOR_COOKIE];
|
||||||
|
if (typeof token === 'string' && token.trim()) {
|
||||||
|
return token.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdentity(socket) {
|
||||||
|
if (!socket) return {};
|
||||||
|
return {
|
||||||
|
clientId: parseClientId(socket),
|
||||||
|
visitorToken: parseVisitorToken(socket),
|
||||||
|
ip: getSocketIp(socket),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUserByIdentity(identity) {
|
||||||
|
const store = loadStore();
|
||||||
|
const users = Object.values(store.users || {});
|
||||||
|
return users.find((user) => {
|
||||||
|
if (identity.clientId && user.clientId === identity.clientId) return true;
|
||||||
|
if (identity.visitorToken && user.visitorToken === identity.visitorToken) return true;
|
||||||
|
if (identity.ip && Array.isArray(user.ips) && user.ips.includes(identity.ip)) return true;
|
||||||
|
return false;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUserByQuery(query) {
|
||||||
|
if (!query) return null;
|
||||||
|
const store = loadStore();
|
||||||
|
const users = Object.values(store.users || {});
|
||||||
|
return users.find((user) => {
|
||||||
|
if (user.id === query) return true;
|
||||||
|
if (user.clientId === query) return true;
|
||||||
|
if (user.visitorToken === query) return true;
|
||||||
|
if (user.lastSocketId === query) return true;
|
||||||
|
if (Array.isArray(user.socketIds) && user.socketIds.includes(query)) return true;
|
||||||
|
if (Array.isArray(user.nicknames) && user.nicknames.includes(query)) return true;
|
||||||
|
if (Array.isArray(user.ips) && user.ips.includes(query)) return true;
|
||||||
|
return false;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserFromSocket(user, socket, identity) {
|
||||||
|
let changed = false;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!user.firstSeen) {
|
||||||
|
user.firstSeen = now;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!user.lastSeen || now > user.lastSeen) {
|
||||||
|
user.lastSeen = now;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
const role = getRole(socket);
|
||||||
|
if (user.lastRole !== role) {
|
||||||
|
user.lastRole = role;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
const nickname = getNickname(socket) || null;
|
||||||
|
if (nickname) {
|
||||||
|
user.nicknames = Array.isArray(user.nicknames) ? user.nicknames : [];
|
||||||
|
if (!user.nicknames.includes(nickname)) {
|
||||||
|
user.nicknames.push(nickname);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (identity.clientId && user.clientId !== identity.clientId) {
|
||||||
|
user.clientId = identity.clientId;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (identity.visitorToken && user.visitorToken !== identity.visitorToken) {
|
||||||
|
user.visitorToken = identity.visitorToken;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (identity.ip) {
|
||||||
|
user.ips = Array.isArray(user.ips) ? user.ips : [];
|
||||||
|
if (!user.ips.includes(identity.ip)) {
|
||||||
|
user.ips.push(identity.ip);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
user.lastIp = identity.ip;
|
||||||
|
}
|
||||||
|
if (user.lastSocketId !== socket.id) {
|
||||||
|
user.lastSocketId = socket.id;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
user.socketIds = Array.isArray(user.socketIds) ? user.socketIds : [];
|
||||||
|
if (!user.socketIds.includes(socket.id)) {
|
||||||
|
user.socketIds.push(socket.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUserForSocket(socket) {
|
||||||
|
const store = loadStore();
|
||||||
|
const identity = buildIdentity(socket);
|
||||||
|
let user = findUserByIdentity(identity);
|
||||||
|
if (!user) {
|
||||||
|
user = {
|
||||||
|
id: uuidv4(),
|
||||||
|
clientId: identity.clientId || null,
|
||||||
|
visitorToken: identity.visitorToken || null,
|
||||||
|
ips: identity.ip ? [identity.ip] : [],
|
||||||
|
nicknames: [],
|
||||||
|
socketIds: [],
|
||||||
|
firstSeen: null,
|
||||||
|
lastSeen: null,
|
||||||
|
lastRole: null,
|
||||||
|
lastSocketId: null,
|
||||||
|
lastIp: identity.ip || null,
|
||||||
|
};
|
||||||
|
store.users[user.id] = user;
|
||||||
|
}
|
||||||
|
const changed = updateUserFromSocket(user, socket, identity);
|
||||||
|
socket.data.moderation = { userId: user.id, ...identity };
|
||||||
|
if (changed) {
|
||||||
|
saveStore(store);
|
||||||
|
emitModerationSnapshot();
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredBans() {
|
||||||
|
const store = loadStore();
|
||||||
|
const now = Date.now();
|
||||||
|
let changed = false;
|
||||||
|
Object.values(store.bans || {}).forEach((ban) => {
|
||||||
|
if (ban.expiresAt && ban.expiresAt <= now) {
|
||||||
|
delete store.bans[ban.id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
saveStore(store);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function banMatchesIdentity(ban, identity) {
|
||||||
|
if (!ban) return false;
|
||||||
|
if (ban.userId && identity.userId && ban.userId === identity.userId) return true;
|
||||||
|
if (ban.clientId && identity.clientId && ban.clientId === identity.clientId) return true;
|
||||||
|
if (ban.visitorToken && identity.visitorToken && ban.visitorToken === identity.visitorToken) return true;
|
||||||
|
if (ban.ip && identity.ip && ban.ip === identity.ip) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveBan(identity) {
|
||||||
|
cleanupExpiredBans();
|
||||||
|
const store = loadStore();
|
||||||
|
const bans = Object.values(store.bans || {});
|
||||||
|
return (
|
||||||
|
bans.find((ban) => {
|
||||||
|
if (ban.expiresAt && ban.expiresAt <= Date.now()) return false;
|
||||||
|
return banMatchesIdentity(ban, identity);
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBannedSocket(socket) {
|
||||||
|
if (!socket || isAdminSocket(socket)) return false;
|
||||||
|
const identity = socket.data?.moderation || buildIdentity(socket);
|
||||||
|
identity.userId = identity.userId || socket.data?.moderation?.userId || null;
|
||||||
|
return Boolean(findActiveBan(identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSocketStatus(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
if (isAdminSocket(socket)) {
|
||||||
|
if (socket.data?.banInfo) {
|
||||||
|
socket.data.banInfo = null;
|
||||||
|
socket.emit('moderation:status', { banned: false });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const identity = { ...(socket.data?.moderation || buildIdentity(socket)) };
|
||||||
|
const ban = findActiveBan(identity);
|
||||||
|
const prevId = socket.data?.banInfo?.id || null;
|
||||||
|
if (!ban && prevId) {
|
||||||
|
socket.data.banInfo = null;
|
||||||
|
socket.emit('moderation:status', { banned: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ban) {
|
||||||
|
socket.data.banInfo = null;
|
||||||
|
socket.emit('moderation:status', { banned: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prevId !== ban.id) {
|
||||||
|
socket.data.banInfo = ban;
|
||||||
|
socket.emit('moderation:status', {
|
||||||
|
banned: true,
|
||||||
|
reason: ban.reason || null,
|
||||||
|
expiresAt: ban.expiresAt || null,
|
||||||
|
createdAt: ban.createdAt || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeUser(user) {
|
||||||
|
const activeBan = findActiveBan({ userId: user.id, clientId: user.clientId, visitorToken: user.visitorToken, ip: user.lastIp });
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
clientId: user.clientId || null,
|
||||||
|
visitorToken: user.visitorToken || null,
|
||||||
|
ips: user.ips || [],
|
||||||
|
nicknames: user.nicknames || [],
|
||||||
|
lastSeen: user.lastSeen || null,
|
||||||
|
firstSeen: user.firstSeen || null,
|
||||||
|
lastRole: user.lastRole || null,
|
||||||
|
lastSocketId: user.lastSocketId || null,
|
||||||
|
ban: activeBan
|
||||||
|
? {
|
||||||
|
id: activeBan.id,
|
||||||
|
reason: activeBan.reason || null,
|
||||||
|
createdAt: activeBan.createdAt || null,
|
||||||
|
expiresAt: activeBan.expiresAt || null,
|
||||||
|
createdBy: activeBan.createdBy || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModerationSnapshot() {
|
||||||
|
cleanupExpiredBans();
|
||||||
|
const store = loadStore();
|
||||||
|
return {
|
||||||
|
users: Object.values(store.users || {}).map(serializeUser),
|
||||||
|
bans: Object.values(store.bans || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitModerationSnapshot() {
|
||||||
|
const payload = getModerationSnapshot();
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (!isAdminSocket(socket)) return;
|
||||||
|
socket.emit('moderation:update', payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBansForUser(user, meta = {}) {
|
||||||
|
if (!user) return false;
|
||||||
|
const store = loadStore();
|
||||||
|
let changed = false;
|
||||||
|
Object.values(store.bans || {}).forEach((ban) => {
|
||||||
|
if (ban.userId === user.id) {
|
||||||
|
delete store.bans[ban.id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (user.clientId && ban.clientId === user.clientId) {
|
||||||
|
delete store.bans[ban.id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (user.visitorToken && ban.visitorToken === user.visitorToken) {
|
||||||
|
delete store.bans[ban.id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (user.lastIp && ban.ip === user.lastIp) {
|
||||||
|
delete store.bans[ban.id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
recordHistory({
|
||||||
|
id: uuidv4(),
|
||||||
|
action: 'unban',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
createdBy: meta.by || null,
|
||||||
|
reason: meta.reason || null,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
saveStore(store);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTarget(target = {}) {
|
||||||
|
if (typeof target === 'string') {
|
||||||
|
const query = target.trim();
|
||||||
|
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(query)) {
|
||||||
|
return { ip: query, query };
|
||||||
|
}
|
||||||
|
return { query };
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBan(target, { durationMs = null, reason = null, createdBy = null } = {}) {
|
||||||
|
cleanupExpiredBans();
|
||||||
|
const store = loadStore();
|
||||||
|
const resolved = resolveTarget(target);
|
||||||
|
const query = resolved.query || null;
|
||||||
|
const user =
|
||||||
|
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
|
||||||
|
const targetSocketId = resolved.socketId || user?.lastSocketId || null;
|
||||||
|
if (targetSocketId) {
|
||||||
|
const targetSocket = io.sockets.sockets.get(targetSocketId);
|
||||||
|
if (targetSocket && isAdminSocket(targetSocket)) {
|
||||||
|
throw new Error('Active admins cannot be banned.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const identity = {
|
||||||
|
userId: user?.id || null,
|
||||||
|
clientId: resolved.clientId || user?.clientId || null,
|
||||||
|
visitorToken: resolved.visitorToken || user?.visitorToken || null,
|
||||||
|
ip: resolved.ip || user?.lastIp || null,
|
||||||
|
};
|
||||||
|
if (!identity.userId && !identity.clientId && !identity.visitorToken && !identity.ip) {
|
||||||
|
throw new Error('Unknown user.');
|
||||||
|
}
|
||||||
|
const cleanReason = typeof reason === 'string' ? reason.trim() : null;
|
||||||
|
const safeDuration = typeof durationMs === 'number' && durationMs > 0 ? durationMs : null;
|
||||||
|
const ban = {
|
||||||
|
id: uuidv4(),
|
||||||
|
userId: identity.userId,
|
||||||
|
clientId: identity.clientId,
|
||||||
|
visitorToken: identity.visitorToken,
|
||||||
|
ip: identity.ip,
|
||||||
|
reason: cleanReason || null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: safeDuration ? Date.now() + safeDuration : null,
|
||||||
|
createdBy: createdBy || null,
|
||||||
|
};
|
||||||
|
Object.values(store.bans || {}).forEach((existing) => {
|
||||||
|
if (banMatchesIdentity(existing, identity)) {
|
||||||
|
delete store.bans[existing.id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
store.bans[ban.id] = ban;
|
||||||
|
recordHistory({
|
||||||
|
id: uuidv4(),
|
||||||
|
action: durationMs ? 'timeout' : 'ban',
|
||||||
|
createdAt: ban.createdAt,
|
||||||
|
createdBy: ban.createdBy,
|
||||||
|
reason: ban.reason,
|
||||||
|
userId: ban.userId || null,
|
||||||
|
banId: ban.id,
|
||||||
|
expiresAt: ban.expiresAt,
|
||||||
|
});
|
||||||
|
saveStore(store);
|
||||||
|
return ban;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBan(target) {
|
||||||
|
cleanupExpiredBans();
|
||||||
|
const store = loadStore();
|
||||||
|
const resolved = resolveTarget(target);
|
||||||
|
const banId = resolved.banId || resolved.query;
|
||||||
|
if (banId && store.bans[banId]) {
|
||||||
|
delete store.bans[banId];
|
||||||
|
saveStore(store);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const query = resolved.query || null;
|
||||||
|
const user =
|
||||||
|
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
|
||||||
|
if (user) {
|
||||||
|
const changed = clearBansForUser(user, { by: resolved.by || null, reason: resolved.reason || null });
|
||||||
|
if (changed) {
|
||||||
|
saveStore(store);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
if (resolved.ip) {
|
||||||
|
let removed = false;
|
||||||
|
Object.values(store.bans || {}).forEach((ban) => {
|
||||||
|
if (ban.ip === resolved.ip) {
|
||||||
|
delete store.bans[ban.id];
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (removed) {
|
||||||
|
saveStore(store);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBan(target, options) {
|
||||||
|
const ban = createBan(target, options);
|
||||||
|
emitModerationSnapshot();
|
||||||
|
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
|
||||||
|
return ban;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUnban(target) {
|
||||||
|
const removed = removeBan(target);
|
||||||
|
if (removed) {
|
||||||
|
emitModerationSnapshot();
|
||||||
|
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSocket(socket) {
|
||||||
|
const user = ensureUserForSocket(socket);
|
||||||
|
refreshSocketStatus(socket);
|
||||||
|
socket.use((packet, next) => {
|
||||||
|
if (!packet || !packet.length) return next();
|
||||||
|
const event = packet[0];
|
||||||
|
if (EVENT_ALLOWLIST.has(event)) return next();
|
||||||
|
if (isAdminSocket(socket)) return next();
|
||||||
|
if (isBannedSocket(socket)) {
|
||||||
|
return next(new Error('banned'));
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const store = loadStore();
|
||||||
|
if (!store.users[user.id]) return;
|
||||||
|
store.users[user.id].lastSeen = Date.now();
|
||||||
|
saveStore(store);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
registerSocket(socket);
|
||||||
|
if (isAdminSocket(socket)) {
|
||||||
|
socket.emit('moderation:init', getModerationSnapshot());
|
||||||
|
}
|
||||||
|
socket.on('moderation:ban', ({ target, durationMs, reason } = {}, cb = () => {}) => {
|
||||||
|
if (!isAdminSocket(socket)) {
|
||||||
|
cb({ error: 'Not authorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ban = applyBan(target, {
|
||||||
|
durationMs: durationMs || null,
|
||||||
|
reason,
|
||||||
|
createdBy: socket?.data?.user?.username || socket.id,
|
||||||
|
});
|
||||||
|
logAdminEvent({
|
||||||
|
label: 'moderation',
|
||||||
|
message: durationMs ? 'User timed out' : 'User banned',
|
||||||
|
ip: socket?.data?.moderation?.ip || null,
|
||||||
|
meta: { target, reason, expiresAt: ban.expiresAt || null },
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
cb({ success: true, ban });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('moderation:unban', ({ target } = {}, cb = () => {}) => {
|
||||||
|
if (!isAdminSocket(socket)) {
|
||||||
|
cb({ error: 'Not authorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const removed = applyUnban({ ...target, by: socket?.data?.user?.username || socket.id });
|
||||||
|
if (removed) {
|
||||||
|
logAdminEvent({
|
||||||
|
label: 'moderation',
|
||||||
|
message: 'User unbanned',
|
||||||
|
ip: socket?.data?.moderation?.ip || null,
|
||||||
|
meta: { target },
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cb({ success: true, removed });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket, role }) => {
|
||||||
|
if (!socket) return;
|
||||||
|
if (ADMIN_ROLES.has(role)) {
|
||||||
|
ensureUserForSocket(socket);
|
||||||
|
refreshSocketStatus(socket);
|
||||||
|
socket.emit('moderation:init', getModerationSnapshot());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nicknameEvents.on('change', ({ socketId }) => {
|
||||||
|
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
||||||
|
if (socket) {
|
||||||
|
const store = loadStore();
|
||||||
|
const identity = buildIdentity(socket);
|
||||||
|
const user = findUserByIdentity(identity);
|
||||||
|
if (user) {
|
||||||
|
const changed = updateUserFromSocket(user, socket, identity);
|
||||||
|
if (changed) {
|
||||||
|
saveStore(store);
|
||||||
|
emitModerationSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const expired = cleanupExpiredBans();
|
||||||
|
if (expired) {
|
||||||
|
emitModerationSnapshot();
|
||||||
|
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
|
||||||
|
}
|
||||||
|
}, 30 * 1000);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildIdentity,
|
||||||
|
getModerationSnapshot,
|
||||||
|
isBannedSocket,
|
||||||
|
findUserByQuery,
|
||||||
|
createBan,
|
||||||
|
removeBan,
|
||||||
|
applyBan,
|
||||||
|
applyUnban,
|
||||||
|
refreshSocketStatus,
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ const ALERT_COLOR = '#8bc34a';
|
|||||||
const { parseSensorFrame } = require('../helpers/sensorDecoder');
|
const { parseSensorFrame } = require('../helpers/sensorDecoder');
|
||||||
const { MODES, getMode } = require('./modeManager');
|
const { MODES, getMode } = require('./modeManager');
|
||||||
const { isAdmin, roleEvents } = require('./roleService');
|
const { isAdmin, roleEvents } = require('./roleService');
|
||||||
|
const { isBannedSocket } = require('./moderationService');
|
||||||
const { publishEvent } = require('./eventBus');
|
const { publishEvent } = require('./eventBus');
|
||||||
const videoSessions = require('./videoSessions');
|
const videoSessions = require('./videoSessions');
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ function lockRover(id, locked, options = {}) {
|
|||||||
if (!record) {
|
if (!record) {
|
||||||
throw new Error('Unknown rover');
|
throw new Error('Unknown rover');
|
||||||
}
|
}
|
||||||
|
const wasAllUnlocked = Array.from(rovers.values()).every((entry) => !entry.locked);
|
||||||
const reason = locked ? options.reason || 'manual' : null;
|
const reason = locked ? options.reason || 'manual' : null;
|
||||||
const silent = Boolean(options.silent);
|
const silent = Boolean(options.silent);
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -118,6 +120,14 @@ function lockRover(id, locked, options = {}) {
|
|||||||
type: 'rover.unlocked',
|
type: 'rover.unlocked',
|
||||||
payload: { roverId: id },
|
payload: { roverId: id },
|
||||||
});
|
});
|
||||||
|
const isAllUnlocked = Array.from(rovers.values()).every((entry) => !entry.locked);
|
||||||
|
if (!wasAllUnlocked && isAllUnlocked) {
|
||||||
|
publishEvent({
|
||||||
|
source: 'roverManager',
|
||||||
|
type: 'rovers.allUnlocked',
|
||||||
|
payload: { roverId: id },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
broadcastRoster();
|
broadcastRoster();
|
||||||
managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason });
|
managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason });
|
||||||
@@ -476,7 +486,13 @@ function isDriver(roverId, socket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canDrive(roverId, socket) {
|
function canDrive(roverId, socket) {
|
||||||
return turnService.canDrive(roverId, socket) || isAdmin(socket);
|
if (isAdmin(socket)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!socket || !isDriver(roverId, socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return turnService.canDrive(roverId, socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoversForSocket(socketId) {
|
function getRoversForSocket(socketId) {
|
||||||
@@ -561,9 +577,11 @@ roleEvents.on('change', ({ socket, role }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.emit('rovers', getRoster());
|
if (!isBannedSocket(socket)) {
|
||||||
if (socket.data?.role === 'spectator') {
|
socket.emit('rovers', getRoster());
|
||||||
enableSpectator(socket);
|
if (socket.data?.role === 'spectator') {
|
||||||
|
enableSpectator(socket);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
|
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
|
||||||
@@ -571,6 +589,9 @@ io.on('connection', (socket) => {
|
|||||||
if (socket.data?.role === 'spectator') {
|
if (socket.data?.role === 'spectator') {
|
||||||
throw new Error('Spectators cannot drive');
|
throw new Error('Spectators cannot drive');
|
||||||
}
|
}
|
||||||
|
if ((getMode() === MODES.ADMIN || getMode() === MODES.LOCKDOWN) && !isAdmin(socket)) {
|
||||||
|
throw new Error('Admins only');
|
||||||
|
}
|
||||||
const targetId = roverId || Array.from(rovers.keys())[0];
|
const targetId = roverId || Array.from(rovers.keys())[0];
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
throw new Error('No rovers available');
|
throw new Error('No rovers available');
|
||||||
@@ -582,8 +603,9 @@ io.on('connection', (socket) => {
|
|||||||
throw new Error(message || 'Switch denied');
|
throw new Error(message || 'Switch denied');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info('Request control', socket.id, targetId, { force });
|
const forceAllowed = Boolean(force) && isAdmin(socket);
|
||||||
requestControl(targetId, socket, { force: Boolean(force), allowUser: true });
|
logger.info('Request control', socket.id, targetId, { force: forceAllowed });
|
||||||
|
requestControl(targetId, socket, { force: forceAllowed, allowUser: true });
|
||||||
previousJoined.forEach((rid) => {
|
previousJoined.forEach((rid) => {
|
||||||
if (rid !== targetId) {
|
if (rid !== targetId) {
|
||||||
releaseControl(rid, socket);
|
releaseControl(rid, socket);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const { getHealthSnapshot } = require('./healthService');
|
|||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
const { getCommunityGoal } = require('./communityGoalService');
|
const { getCommunityGoal } = require('./communityGoalService');
|
||||||
const { subscribe } = require('./eventBus');
|
const { subscribe } = require('./eventBus');
|
||||||
|
const { isBannedSocket } = require('./moderationService');
|
||||||
|
|
||||||
const discordInvite = loadConfig().discord?.invite || null;
|
const discordInvite = loadConfig().discord?.invite || null;
|
||||||
const kofiLink = loadConfig().kofi?.link || null;
|
const kofiLink = loadConfig().kofi?.link || null;
|
||||||
@@ -68,6 +69,7 @@ function buildSession(socket) {
|
|||||||
|
|
||||||
function syncSocket(socket) {
|
function syncSocket(socket) {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
if (isBannedSocket(socket)) return;
|
||||||
const payload = buildSession(socket);
|
const payload = buildSession(socket);
|
||||||
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
||||||
socket.emit('session:sync', payload);
|
socket.emit('session:sync', payload);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const { getMode, MODES } = require('./modeManager');
|
|||||||
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
const { getRequestIp } = require('../helpers/ipResolver');
|
||||||
|
const { logAdminEvent } = require('./adminLogService');
|
||||||
|
const { isBannedSocket } = require('./moderationService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const mediaConfig = config.media || {};
|
const mediaConfig = config.media || {};
|
||||||
@@ -76,10 +79,18 @@ app.post('/mediamtx/auth', (req, res) => {
|
|||||||
const sessionId = body.user;
|
const sessionId = body.user;
|
||||||
const action = (body.action || '').toLowerCase();
|
const action = (body.action || '').toLowerCase();
|
||||||
const protocol = (body.protocol || '').toLowerCase();
|
const protocol = (body.protocol || '').toLowerCase();
|
||||||
const ip = body.ip || req.ip || '';
|
const ip = getRequestIp(req, body.ip);
|
||||||
const streamInfo = extractStreamInfo(path);
|
const streamInfo = extractStreamInfo(path);
|
||||||
|
|
||||||
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol, ip });
|
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol });
|
||||||
|
if (ip) {
|
||||||
|
logAdminEvent({
|
||||||
|
label: 'mediamtx',
|
||||||
|
message: 'Media auth request',
|
||||||
|
ip,
|
||||||
|
meta: { path: body.path, sessionId, stream: streamInfo, action, protocol },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'read' && protocol === 'srt' && streamInfo?.id) {
|
if (action === 'read' && protocol === 'srt' && streamInfo?.id) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
@@ -100,6 +111,9 @@ app.post('/mediamtx/auth', (req, res) => {
|
|||||||
videoSessions.revokeSession(sessionId);
|
videoSessions.revokeSession(sessionId);
|
||||||
return res.status(401).end();
|
return res.status(401).end();
|
||||||
}
|
}
|
||||||
|
if (isBannedSocket(socket)) {
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
if (!canView(socket)) {
|
if (!canView(socket)) {
|
||||||
return res.status(401).end();
|
return res.status(401).end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
|||||||
const videoSessions = require('./videoSessions');
|
const videoSessions = require('./videoSessions');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
const { isBannedSocket } = require('./moderationService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const mediaConfig = config.media || {};
|
const mediaConfig = config.media || {};
|
||||||
@@ -80,6 +81,9 @@ function normalizeRequest(payload = {}) {
|
|||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('video:request', (payload = {}, cb = () => {}) => {
|
socket.on('video:request', (payload = {}, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
if (isBannedSocket(socket)) {
|
||||||
|
throw new Error('Banned');
|
||||||
|
}
|
||||||
const target = normalizeRequest(payload);
|
const target = normalizeRequest(payload);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error('video source required');
|
throw new Error('video source required');
|
||||||
|
|||||||
+10
-15
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||||
import ControlSummary, { RoverRosterPanel } from './components/ControlSummary.jsx';
|
|
||||||
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
||||||
import AlertFeed from './components/AlertFeed.jsx';
|
import AlertFeed from './components/AlertFeed.jsx';
|
||||||
import MobileControls, {
|
import MobileControls, {
|
||||||
@@ -16,7 +15,7 @@ import RightPaneTabs from './components/RightPaneTabs.jsx';
|
|||||||
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
||||||
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
|
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
|
||||||
import TurnAlertListener from './components/TurnAlertListener.jsx';
|
import TurnAlertListener from './components/TurnAlertListener.jsx';
|
||||||
import UserListPanel from './components/UserListPanel.jsx';
|
import RawUserPilePanel from './components/RawUserPilePanel.jsx';
|
||||||
import ChatPanel from './components/ChatPanel.jsx';
|
import ChatPanel from './components/ChatPanel.jsx';
|
||||||
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
|
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
|
||||||
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
||||||
@@ -27,6 +26,8 @@ import SettingsPanel from './components/SettingsPanel.jsx';
|
|||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
|
||||||
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||||
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
||||||
|
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
|
||||||
|
import BannedOverlay from './components/BannedOverlay.jsx';
|
||||||
|
|
||||||
function useLayoutMode() {
|
function useLayoutMode() {
|
||||||
const [mode, setMode] = useState(() => {
|
const [mode, setMode] = useState(() => {
|
||||||
@@ -61,16 +62,9 @@ function useLayoutMode() {
|
|||||||
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-0.5 overflow-hidden">
|
<div className="flex h-full gap-0.5 overflow-hidden">
|
||||||
<div className="flex min-w-0 flex-[1.8] flex-col gap-0.5 overflow-y-auto pr-0.5">
|
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
|
||||||
<DriverVideoPanel />
|
<DriverVideoPanel />
|
||||||
<div className="grid h-52 grid-cols-2 gap-0.5">
|
<TelemetryPanel />
|
||||||
<div className="h-full min-h-0">
|
|
||||||
<UserListPanel fillHeight />
|
|
||||||
</div>
|
|
||||||
<div className="h-full min-h-0">
|
|
||||||
<ChatPanel fillHeight />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
@@ -101,7 +95,7 @@ function MobileFeatureTabs({
|
|||||||
<TabPanel id="chat">
|
<TabPanel id="chat">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
<UserListPanel />
|
<RawUserPilePanel />
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="roomcontrols">
|
<TabPanel id="roomcontrols">
|
||||||
@@ -133,7 +127,7 @@ function MobilePortraitLayout({ onOpenHelpOverlay }) {
|
|||||||
<MobileControls />
|
<MobileControls />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||||
<RoverRosterPanel />
|
<RoverQueuesPanel />
|
||||||
</div>
|
</div>
|
||||||
{/* <ControlSummary /> */}
|
{/* <ControlSummary /> */}
|
||||||
<MobileFeatureTabs
|
<MobileFeatureTabs
|
||||||
@@ -154,13 +148,13 @@ function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
|||||||
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||||
<RoverRosterPanel />
|
<RoverQueuesPanel />
|
||||||
</div>
|
</div>
|
||||||
{/* <TelemetryPanel /> */}
|
{/* <TelemetryPanel /> */}
|
||||||
</div>
|
</div>
|
||||||
<MobileLandscapeControlColumn />
|
<MobileLandscapeControlColumn />
|
||||||
</section>
|
</section>
|
||||||
<div className="flex flex-col gap-0.5 pb-0.5">
|
<div className="flex flex-col gap-0.5 pb-0">
|
||||||
<MobileFeatureTabs
|
<MobileFeatureTabs
|
||||||
layout="mobile-landscape"
|
layout="mobile-landscape"
|
||||||
onOpenHelpOverlay={onOpenHelpOverlay}
|
onOpenHelpOverlay={onOpenHelpOverlay}
|
||||||
@@ -242,6 +236,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
<AlertFeed />
|
<AlertFeed />
|
||||||
<TurnAlertListener />
|
<TurnAlertListener />
|
||||||
<ModeGateOverlay />
|
<ModeGateOverlay />
|
||||||
|
<BannedOverlay />
|
||||||
<HelpOverlay
|
<HelpOverlay
|
||||||
visible={helpVisible}
|
visible={helpVisible}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ const MODES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const { session, lockRover, setMode, requestControl, setCommunityGoal } = useSession();
|
const {
|
||||||
|
session,
|
||||||
|
lockRover,
|
||||||
|
setMode,
|
||||||
|
requestControl,
|
||||||
|
setCommunityGoal,
|
||||||
|
adminLogs,
|
||||||
|
moderation,
|
||||||
|
banUser,
|
||||||
|
timeoutUser,
|
||||||
|
unbanUser,
|
||||||
|
} = useSession();
|
||||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
const health = session?.health || null;
|
const health = session?.health || null;
|
||||||
@@ -135,6 +146,13 @@ export default function AdminPanel() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ReplaySnapshotHealth health={health} />
|
<ReplaySnapshotHealth health={health} />
|
||||||
|
<ModerationPanel
|
||||||
|
moderation={moderation}
|
||||||
|
onBan={banUser}
|
||||||
|
onTimeout={timeoutUser}
|
||||||
|
onUnban={unbanUser}
|
||||||
|
/>
|
||||||
|
<AdminIpLogPanel entries={adminLogs} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,7 +185,7 @@ function ReplaySnapshotHealth({ health }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25 text-xs text-slate-300">
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
{replay.sources.map((source) => (
|
{replay.sources.map((source) => (
|
||||||
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
|
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
|
||||||
<span>{source.label}</span>
|
<span>{source.label}</span>
|
||||||
@@ -177,7 +195,7 @@ function ReplaySnapshotHealth({ health }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25 text-xs text-slate-300">
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
{snapshots.rovers.map((entry) => (
|
{snapshots.rovers.map((entry) => (
|
||||||
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
|
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
|
||||||
<span>{entry.name}</span>
|
<span>{entry.name}</span>
|
||||||
@@ -187,7 +205,7 @@ function ReplaySnapshotHealth({ health }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25 text-xs text-slate-300">
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
{snapshots.rooms.map((entry) => (
|
{snapshots.rooms.map((entry) => (
|
||||||
<div key={`room:${entry.id}`} className="flex items-center justify-between">
|
<div key={`room:${entry.id}`} className="flex items-center justify-between">
|
||||||
<span>{entry.name}</span>
|
<span>{entry.name}</span>
|
||||||
@@ -200,3 +218,222 @@ function ReplaySnapshotHealth({ health }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModerationPanel({ moderation, onBan, onTimeout, onUnban }) {
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [timeoutMinutes, setTimeoutMinutes] = useState(30);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const users = moderation?.users || [];
|
||||||
|
const normalized = filter.trim().toLowerCase();
|
||||||
|
const filtered = normalized
|
||||||
|
? users.filter((user) => {
|
||||||
|
const label = user.nicknames?.[user.nicknames.length - 1] || user.id || '';
|
||||||
|
return (
|
||||||
|
label.toLowerCase().includes(normalized) ||
|
||||||
|
String(user.id).toLowerCase().includes(normalized) ||
|
||||||
|
String(user.lastSocketId || '').toLowerCase().includes(normalized)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: users;
|
||||||
|
|
||||||
|
const handleBan = async (user) => {
|
||||||
|
try {
|
||||||
|
await onBan({ userId: user.id }, reason.trim() || null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeout = async (user) => {
|
||||||
|
const durationMs = Math.max(1, Number(timeoutMinutes) || 0) * 60 * 1000;
|
||||||
|
try {
|
||||||
|
await onTimeout({ userId: user.id }, durationMs, reason.trim() || null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnban = async (user) => {
|
||||||
|
try {
|
||||||
|
await onUnban({ userId: user.id });
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span className="panel-muted text-xs uppercase">Moderation</span>
|
||||||
|
<span className="text-slate-500">{filtered.length} users</span>
|
||||||
|
</div>
|
||||||
|
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
|
<input
|
||||||
|
className="field-input flex-1 min-w-[10rem] text-xs"
|
||||||
|
placeholder="Search users"
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="field-input flex-1 min-w-[12rem] text-xs"
|
||||||
|
placeholder="Reason (optional)"
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-0.25 text-xs text-slate-400">
|
||||||
|
<span>Timeout (min)</span>
|
||||||
|
<input
|
||||||
|
className="field-input w-16 text-xs"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={timeoutMinutes}
|
||||||
|
onChange={(event) => setTimeoutMinutes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">No users tracked yet.</p>
|
||||||
|
) : (
|
||||||
|
filtered
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
|
||||||
|
.map((user) => {
|
||||||
|
const label = user.nicknames?.[user.nicknames.length - 1] || user.id.slice(0, 6);
|
||||||
|
const ban = user.ban || null;
|
||||||
|
return (
|
||||||
|
<div key={user.id} className="surface-muted space-y-0.25 text-xs">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
|
<span className="text-slate-100">{label}</span>
|
||||||
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
|
||||||
|
{user.id.slice(0, 6)}
|
||||||
|
</span>
|
||||||
|
{ban && (
|
||||||
|
<span className="rounded bg-red-500/30 px-1 text-[0.7rem] text-red-200">
|
||||||
|
{ban.expiresAt ? 'Timeout' : 'Banned'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-0.25">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-dark text-[0.7rem]"
|
||||||
|
onClick={() => handleBan(user)}
|
||||||
|
>
|
||||||
|
Ban
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-dark text-[0.7rem]"
|
||||||
|
onClick={() => handleTimeout(user)}
|
||||||
|
>
|
||||||
|
Timeout
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-danger text-[0.7rem]"
|
||||||
|
onClick={() => handleUnban(user)}
|
||||||
|
disabled={!ban}
|
||||||
|
>
|
||||||
|
Unban
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 text-[0.7rem] text-slate-400">
|
||||||
|
{user.lastSocketId && <span>socket {user.lastSocketId.slice(0, 6)}</span>}
|
||||||
|
{user.lastSeen && <span>last seen {new Date(user.lastSeen).toLocaleString()}</span>}
|
||||||
|
{ban?.expiresAt && (
|
||||||
|
<span>expires {new Date(ban.expiresAt).toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
{user.ips?.length ? (
|
||||||
|
<span>ips {user.ips.slice(-3).join(', ')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModerationBanList bans={moderation?.bans || []} onUnban={onUnban} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModerationBanList({ bans, onUnban }) {
|
||||||
|
if (!bans.length) {
|
||||||
|
return (
|
||||||
|
<div className="surface text-xs text-slate-500">
|
||||||
|
No active bans/timeouts.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||||
|
<div className="panel-muted text-xs uppercase text-slate-400">Active bans</div>
|
||||||
|
{bans.map((ban) => (
|
||||||
|
<div key={ban.id} className="flex flex-wrap items-center justify-between gap-0.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
|
||||||
|
{ban.id.slice(0, 6)}
|
||||||
|
</span>
|
||||||
|
{ban.userId && (
|
||||||
|
<span className="text-[0.7rem] text-slate-400">user {ban.userId.slice(0, 6)}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[0.7rem] text-slate-300">
|
||||||
|
{ban.expiresAt ? 'timeout' : 'ban'}
|
||||||
|
</span>
|
||||||
|
{ban.expiresAt && (
|
||||||
|
<span className="text-[0.7rem] text-slate-400">
|
||||||
|
until {new Date(ban.expiresAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ban.reason ? (
|
||||||
|
<span className="text-[0.7rem] text-slate-400">reason {ban.reason}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-danger text-[0.7rem]"
|
||||||
|
onClick={() => onUnban({ banId: ban.id })}
|
||||||
|
>
|
||||||
|
Unban
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminIpLogPanel({ entries }) {
|
||||||
|
const logs = entries || [];
|
||||||
|
return (
|
||||||
|
<div className="panel-section space-y-0.5 text-base">
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||||
|
<span>Admin IP log</span>
|
||||||
|
<span>{logs.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p>No admin log entries yet.</p>
|
||||||
|
) : (
|
||||||
|
logs
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((entry) => (
|
||||||
|
<div key={entry.id} className="surface">
|
||||||
|
<span className="text-amber-400">
|
||||||
|
{entry.ts ? new Date(entry.ts).toLocaleTimeString() : '--'}
|
||||||
|
</span>{' '}
|
||||||
|
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||||
|
<span className="text-slate-200">{entry.message}</span>{' '}
|
||||||
|
{entry.ip && <span className="text-cyan-300">{entry.ip}</span>}{' '}
|
||||||
|
{entry.meta && <span className="text-slate-500">{JSON.stringify(entry.meta)}</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">Admin-only log stream; IPs never appear in user data.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import ChatMessageRow from './ChatMessageRow.jsx';
|
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||||
|
import ChatTypingRow from './ChatTypingRow.jsx';
|
||||||
|
|
||||||
const LIFETIME_MS = 3000;
|
const LIFETIME_MS = 3000;
|
||||||
const DEFAULT_COLOR = '#2196f3';
|
const DEFAULT_COLOR = '#2196f3';
|
||||||
@@ -11,7 +12,7 @@ function buildKey(alert) {
|
|||||||
return `${alert.title || 'alert'}-${alert.message}`;
|
return `${alert.title || 'alert'}-${alert.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlertFeed() {
|
export default function AlertFeed({ scale = 1 }) {
|
||||||
const { alerts } = useSession();
|
const { alerts } = useSession();
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const latest = useMemo(() => alerts.slice(-3).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
const latest = useMemo(() => alerts.slice(-3).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||||
@@ -31,8 +32,20 @@ export default function AlertFeed() {
|
|||||||
|
|
||||||
if (!visible.length) return null;
|
if (!visible.length) return null;
|
||||||
|
|
||||||
|
const containerStyle =
|
||||||
|
scale === 1
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
transform: `translateX(-50%) scale(${scale})`,
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
};
|
||||||
|
const containerClass =
|
||||||
|
scale === 1
|
||||||
|
? 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5'
|
||||||
|
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5">
|
<div className={containerClass} style={containerStyle}>
|
||||||
{visible.map((toast) => (
|
{visible.map((toast) => (
|
||||||
<AlertToast key={toast.key} alert={toast.alert} />
|
<AlertToast key={toast.key} alert={toast.alert} />
|
||||||
))}
|
))}
|
||||||
@@ -56,6 +69,9 @@ function AlertToast({ alert }) {
|
|||||||
if (alert.kind === 'chat' && alert.payload) {
|
if (alert.kind === 'chat' && alert.payload) {
|
||||||
return <ChatMessageRow message={alert.payload} />;
|
return <ChatMessageRow message={alert.payload} />;
|
||||||
}
|
}
|
||||||
|
if (alert.kind === 'chat-typing' && alert.payload) {
|
||||||
|
return <ChatTypingRow message={alert.payload} />;
|
||||||
|
}
|
||||||
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
|
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
|
||||||
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
|
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
|
||||||
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
|
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import AuthPanel from './AuthPanel.jsx';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
function formatExpiry(expiresAt) {
|
||||||
|
if (!expiresAt) return 'permanent';
|
||||||
|
const ms = expiresAt - Date.now();
|
||||||
|
if (ms <= 0) return 'expiring soon';
|
||||||
|
const minutes = Math.ceil(ms / 60000);
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
||||||
|
const hours = Math.ceil(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
const days = Math.ceil(hours / 24);
|
||||||
|
return `${days} day${days === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannedOverlay() {
|
||||||
|
const { banStatus } = useSession();
|
||||||
|
if (!banStatus?.banned) return null;
|
||||||
|
const expiresAt = banStatus.expiresAt || null;
|
||||||
|
const reason = banStatus.reason || null;
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/90 px-0.5 py-0.5">
|
||||||
|
<div className="surface w-full max-w-xl space-y-0.5 text-slate-100 shadow-2xl">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-lg font-semibold text-red-300">Access blocked</p>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
Your access has been {expiresAt ? 'temporarily restricted' : 'banned'}.
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{expiresAt ? `Timeout ends in ${formatExpiry(expiresAt)}.` : 'This ban has no expiration.'}
|
||||||
|
</div>
|
||||||
|
{reason && <div className="text-xs text-slate-400">Reason: {reason}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="surface-muted">
|
||||||
|
<AuthPanel />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Admins can log in above to regain access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ export default function CameraServoPanel() {
|
|||||||
onTouchEnd={commitSlider}
|
onTouchEnd={commitSlider}
|
||||||
onPointerUp={commitSlider}
|
onPointerUp={commitSlider}
|
||||||
/>
|
/>
|
||||||
<div className="mt-0.5 flex justify-between text-xs text-slate-400">
|
<div className="mt-0 flex justify-between text-xs text-slate-400">
|
||||||
<span>{formatDegrees(min)}</span>
|
<span>{formatDegrees(min)}</span>
|
||||||
<span>{formatDegrees(max)}</span>
|
<span>{formatDegrees(max)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,23 +57,12 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessageRow({ message }) {
|
export function ChatIdentity({ message }) {
|
||||||
const isAdmin =
|
|
||||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
|
||||||
const discordLabel = message.fromDiscord
|
const discordLabel = message.fromDiscord
|
||||||
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
|
|
||||||
isAdmin
|
|
||||||
? 'border border-amber-400/30'
|
|
||||||
: message.fromDiscord
|
|
||||||
? 'border border-indigo-400/30 bg-indigo-900/20'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{message.fromDiscord ? (
|
{message.fromDiscord ? (
|
||||||
<>
|
<>
|
||||||
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
|
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
|
||||||
@@ -88,8 +77,28 @@ export default function ChatMessageRow({ message }) {
|
|||||||
{displayName(message)}
|
{displayName(message)}
|
||||||
</span>
|
</span>
|
||||||
{message.roverId && (
|
{message.roverId && (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {message.roverId}</span>
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{message.roverId}</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatRowClass(message) {
|
||||||
|
const isAdmin =
|
||||||
|
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||||
|
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
||||||
|
isAdmin
|
||||||
|
? 'border border-amber-400/30'
|
||||||
|
: message.fromDiscord
|
||||||
|
? 'border border-indigo-400/30 bg-indigo-900/20'
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatMessageRow({ message }) {
|
||||||
|
return (
|
||||||
|
<div className={chatRowClass(message)}>
|
||||||
|
<ChatIdentity message={message} />
|
||||||
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
|
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
|
||||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
||||||
{formatTime(message.ts)}
|
{formatTime(message.ts)}
|
||||||
@@ -97,3 +106,5 @@ export default function ChatMessageRow({ message }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { chatRowClass };
|
||||||
|
|||||||
@@ -3,13 +3,23 @@ import { useChat } from '../context/ChatContext.jsx';
|
|||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import ChatMessageRow from './ChatMessageRow.jsx';
|
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||||
|
import ChatTypingRow from './ChatTypingRow.jsx';
|
||||||
|
|
||||||
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
||||||
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
||||||
|
|
||||||
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
|
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
const {
|
||||||
|
messages,
|
||||||
|
typing,
|
||||||
|
sendMessage,
|
||||||
|
registerInputRef,
|
||||||
|
onInputFocus,
|
||||||
|
onInputBlur,
|
||||||
|
blurChat,
|
||||||
|
setTypingActive,
|
||||||
|
} = useChat();
|
||||||
const {
|
const {
|
||||||
value: ttsSettings,
|
value: ttsSettings,
|
||||||
save: saveTtsSettings,
|
save: saveTtsSettings,
|
||||||
@@ -31,11 +41,12 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||||
|
|
||||||
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
||||||
|
const typingRows = useMemo(() => typing || [], [typing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!listRef.current) return;
|
if (!listRef.current) return;
|
||||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
}, [sorted]);
|
}, [sorted, typingRows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextEngine = ttsSettings?.engine || 'flite';
|
const nextEngine = ttsSettings?.engine || 'flite';
|
||||||
@@ -74,6 +85,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
await sendMessage(clean, ttsPayload);
|
await sendMessage(clean, ttsPayload);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
blurChat();
|
blurChat();
|
||||||
|
setTypingActive(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,25 +97,39 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
||||||
<div className={`surface overflow-y-auto space-y-0.25 ${listClass}`} ref={listRef}>
|
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 && typingRows.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||||
) : (
|
) : (
|
||||||
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
|
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
|
||||||
)}
|
)}
|
||||||
|
{typingRows.map((entry) => (
|
||||||
|
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!hideInput && (
|
{!hideInput && (
|
||||||
<form className="flex flex-wrap items-start gap-0.5" onSubmit={handleSend}>
|
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1 min-w-[10rem]"
|
className="field-input flex-1 min-w-[10rem]"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => {
|
||||||
onFocus={onInputFocus}
|
const next = e.target.value;
|
||||||
onBlur={onInputBlur}
|
setDraft(next);
|
||||||
|
setTypingActive(Boolean(next.trim()));
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
onInputFocus(event);
|
||||||
|
setTypingActive(Boolean(draft.trim()));
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
onInputBlur(event);
|
||||||
|
setTypingActive(false);
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' && !draft.trim()) {
|
if (event.key === 'Enter' && !draft.trim()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
blurChat();
|
blurChat();
|
||||||
|
setTypingActive(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={(el) => registerInputRef(el, { target: 'panel' })}
|
ref={(el) => registerInputRef(el, { target: 'panel' })}
|
||||||
@@ -112,7 +138,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
/>
|
/>
|
||||||
{ttsSupported && (
|
{ttsSupported && (
|
||||||
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
|
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
|
||||||
<label className="flex items-center gap-0.25 text-xs text-slate-300">
|
<label className="flex items-center gap-0.5 text-xs text-slate-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={speak}
|
checked={speak}
|
||||||
@@ -171,7 +197,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canChat || sending}
|
disabled={!canChat || sending}
|
||||||
className="button-dark disabled:opacity-50 self-start"
|
className="button-dark h-full disabled:opacity-50 self-stretch"
|
||||||
>
|
>
|
||||||
{sending ? '...' : 'Send'}
|
{sending ? '...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ChatIdentity, chatRowClass } from './ChatMessageRow.jsx';
|
||||||
|
|
||||||
|
export default function ChatTypingRow({ message }) {
|
||||||
|
return (
|
||||||
|
<div className={`${chatRowClass(message)} italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
|
||||||
|
<ChatIdentity message={message} />
|
||||||
|
<span className="text-slate-300">typing...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { useControlSystem } from '../controls/index.js';
|
|||||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||||
import TopDownMap from './TopDownMap.jsx';
|
import TopDownMap from './TopDownMap.jsx';
|
||||||
import RoverRoster from './RoverRoster.jsx';
|
import RoverRoster from './RoverRoster.jsx';
|
||||||
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
|
||||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
|
|
||||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||||
@@ -46,7 +45,7 @@ export function RoverRosterPanel({ title = 'Rovers' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ControlSummary({ showRoster = true }) {
|
export default function ControlSummary() {
|
||||||
const {
|
const {
|
||||||
state: { roverId, keymap },
|
state: { roverId, keymap },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
@@ -57,32 +56,24 @@ export default function ControlSummary({ showRoster = true }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] md:min-h-[22rem]">
|
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||||
<div className="flex h-full w-full items-stretch justify-center">
|
<div className="flex h-full w-full items-stretch justify-center">
|
||||||
<div className="aspect-square h-full w-full">
|
<div className="aspect-square h-full w-full">
|
||||||
<TopDownMap sensors={sensors} />
|
<TopDownMap sensors={sensors} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.75">
|
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.5">
|
||||||
<div className="row-span-2">
|
<div className="row-span-2">
|
||||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||||
</div>
|
</div>
|
||||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showRoster ? (
|
|
||||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
|
||||||
<div>
|
|
||||||
<ReplaySourcesPanel />
|
|
||||||
</div>
|
|
||||||
<RoverRosterPanel />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineCameraTilt({ keymap }) {
|
export function InlineCameraTilt({ keymap }) {
|
||||||
const {
|
const {
|
||||||
state: { roverId, camera },
|
state: { roverId, camera },
|
||||||
actions: { setServoAngle },
|
actions: { setServoAngle },
|
||||||
@@ -143,12 +134,12 @@ function InlineCameraTilt({ keymap }) {
|
|||||||
onTouchEnd={commitSlider}
|
onTouchEnd={commitSlider}
|
||||||
onPointerUp={commitSlider}
|
onPointerUp={commitSlider}
|
||||||
/>
|
/>
|
||||||
<div className="mt-0.25 flex items-center justify-between text-[0.7rem] text-slate-400">
|
<div className="mt-0 flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||||
<span className="flex items-center gap-0.25">
|
<span className="flex items-center gap-0.5">
|
||||||
{downLabel ? <KeyPill label={downLabel} /> : null}
|
{downLabel ? <KeyPill label={downLabel} /> : null}
|
||||||
{formatDegrees(min)}
|
{formatDegrees(min)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-0.25">
|
<span className="flex items-center gap-0.5">
|
||||||
{formatDegrees(max)}
|
{formatDegrees(max)}
|
||||||
{upLabel ? <KeyPill label={upLabel} /> : null}
|
{upLabel ? <KeyPill label={upLabel} /> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSession } from "../context/SessionContext";
|
import { useSession } from "../context/SessionContext";
|
||||||
import { FaDiscord } from "react-icons/fa";
|
import { FaDiscord } from "react-icons/fa";
|
||||||
|
|
||||||
export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
export default function DiscordInviteButton({ text = "Join our Discord!", className = "" }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const discordInvite = session?.discord?.invite || null;
|
const discordInvite = session?.discord?.invite || null;
|
||||||
|
|
||||||
@@ -12,12 +12,12 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
|||||||
href={discordInvite}
|
href={discordInvite}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center"
|
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center gap-1 ${className}`}
|
||||||
// animated rainbow backgound
|
// animated rainbow backgound
|
||||||
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
||||||
>
|
>
|
||||||
<FaDiscord className="mr-2" />
|
<FaDiscord className="mr-0" />
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
||||||
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
|
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
|
||||||
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
||||||
<StepList steps={instructions.steps} tone="indigo" />
|
<StepList steps={instructions.steps} tone="indigo" />
|
||||||
@@ -186,7 +186,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
|
|||||||
disabled={driveDisabled}
|
disabled={driveDisabled}
|
||||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
|
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.25 w-full">
|
<div className="space-y-0.5 w-full">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
|
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
|
||||||
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
||||||
@@ -217,7 +217,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
|
|||||||
onClick={handleReturnToDrive}
|
onClick={handleReturnToDrive}
|
||||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
|
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.25 w-full">
|
<div className="space-y-0.5 w-full">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
<span className="text-base font-semibold text-amber-50">Docking in Progress</span>
|
<span className="text-base font-semibold text-amber-50">Docking in Progress</span>
|
||||||
{!isMobile ? <ActionPill label="Click to return to driving mode" tone="amber" /> : null}
|
{!isMobile ? <ActionPill label="Click to return to driving mode" tone="amber" /> : null}
|
||||||
@@ -299,10 +299,10 @@ function StepList({ steps, tone = 'emerald' }) {
|
|||||||
{steps.map((step, idx) => (
|
{steps.map((step, idx) => (
|
||||||
<div key={step} className="text-[0.85rem] leading-snug break-words">
|
<div key={step} className="text-[0.85rem] leading-snug break-words">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<span className={`mr-0.35 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
|
<span className={`mr-0 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
|
||||||
<span className="align-top">{step}</span>
|
<span className="align-top">{step}</span>
|
||||||
</div>
|
</div>
|
||||||
{idx < steps.length - 1 ? <div className="mt-0.35 h-px bg-white/10" /> : null}
|
{idx < steps.length - 1 ? <div className="mt-0 h-px bg-white/10" /> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
|||||||
<p className="text-base font-semibold">{title}</p>
|
<p className="text-base font-semibold">{title}</p>
|
||||||
<p className="text-sm text-white/90">{description}</p>
|
<p className="text-sm text-white/90">{description}</p>
|
||||||
{/* center statuses in button */}
|
{/* center statuses in button */}
|
||||||
<div className="mt-0.5 flex flex-wrap gap-0.5 items-center w-full justify-center">
|
<div className="mt-0 flex flex-wrap gap-0.5 items-center w-full justify-center">
|
||||||
{statuses.map((status) => (
|
{statuses.map((status) => (
|
||||||
<span
|
<span
|
||||||
key={status.label}
|
key={status.label}
|
||||||
@@ -87,7 +87,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>}
|
{footnote && <p className="mt-0 text-xs text-emerald-50/80">{footnote}</p>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const {
|
const {
|
||||||
state: { song, lastControlIntentAt },
|
state: { song, lastControlIntentAt },
|
||||||
|
overcurrentLimiter,
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [turnCueVisible, setTurnCueVisible] = useState(false);
|
const [turnCueVisible, setTurnCueVisible] = useState(false);
|
||||||
@@ -122,6 +123,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
telemetryFrame={frame}
|
telemetryFrame={frame}
|
||||||
batteryConfig={batteryConfig}
|
batteryConfig={batteryConfig}
|
||||||
layoutFormat={layoutFormat}
|
layoutFormat={layoutFormat}
|
||||||
|
overcurrentLimiter={overcurrentLimiter}
|
||||||
songNote={song?.note}
|
songNote={song?.note}
|
||||||
qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null}
|
qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null}
|
||||||
showTurnCue={turnCueVisible}
|
showTurnCue={turnCueVisible}
|
||||||
@@ -131,10 +133,10 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
idleSkipSeconds={idleSkipSeconds}
|
idleSkipSeconds={idleSkipSeconds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
||||||
<p>You are not assigned to a rover.</p>
|
<p>You are not assigned to a rover.</p>
|
||||||
{/* colored button to visit the spectator page */}
|
{/* colored button to visit the spectator page */}
|
||||||
<p className="mt-2">
|
<p className="mt-0">
|
||||||
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
||||||
Click here to visit the spectator page.
|
Click here to visit the spectator page.
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
|
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
|
||||||
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
|
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
|
||||||
<div className="space-y-2 p-4 text-sm text-slate-100">
|
<div className="space-y-0.5 p-4 text-sm text-slate-100">
|
||||||
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
|
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
|
||||||
{isIOSMode ? (
|
{isIOSMode ? (
|
||||||
<p className="text-slate-300">
|
<p className="text-slate-300">
|
||||||
@@ -18,7 +18,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
|||||||
via the system back or home gesture.
|
via the system back or home gesture.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-1 text-sm">
|
<div className="flex justify-end gap-0.5 pt-1 text-sm">
|
||||||
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
||||||
{isIOSMode ? 'Got it' : 'Not now'}
|
{isIOSMode ? 'Got it' : 'Not now'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
|
|||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
className="mt-0.5 w-full accent-emerald-400"
|
className="mt-0 w-full accent-emerald-400"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -187,7 +187,7 @@ export default function GamepadMappingSettings() {
|
|||||||
: 'Connect a controller to configure.'}
|
: 'Connect a controller to configure.'}
|
||||||
</p>
|
</p>
|
||||||
{capture && (
|
{capture && (
|
||||||
<p className="mt-0.5 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ function renderLine(line, keymap, idx) {
|
|||||||
function Hero({ hero, keymap }) {
|
function Hero({ hero, keymap }) {
|
||||||
if (!hero) return null;
|
if (!hero) return null;
|
||||||
return (
|
return (
|
||||||
<div className="surface space-y-0.25 px-0.5 py-0.5">
|
<div className="surface space-y-0.5 px-0.5 py-0.5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-white">{hero.title}</p>
|
<p className="text-sm font-semibold text-white">{hero.title}</p>
|
||||||
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
|
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
{hero.chips && (
|
{hero.chips && (
|
||||||
<div className="flex flex-wrap gap-0.25 text-[0.7rem] text-slate-200">
|
<div className="flex flex-wrap gap-0.5 text-[0.7rem] text-slate-200">
|
||||||
{hero.chips.map((chip) => (
|
{hero.chips.map((chip) => (
|
||||||
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
|
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
|
||||||
{chip}
|
{chip}
|
||||||
@@ -57,7 +57,7 @@ function Hero({ hero, keymap }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hero.bullets && (
|
{hero.bullets && (
|
||||||
<ul className="space-y-0.25 text-[0.85rem] text-slate-200">
|
<ul className="space-y-0.5 text-[0.85rem] text-slate-200">
|
||||||
{hero.bullets.map((line, idx) => {
|
{hero.bullets.map((line, idx) => {
|
||||||
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
|
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
|
||||||
return (
|
return (
|
||||||
@@ -74,13 +74,13 @@ function Hero({ hero, keymap }) {
|
|||||||
|
|
||||||
function ListBlock({ block, keymap }) {
|
function ListBlock({ block, keymap }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
||||||
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
|
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
|
||||||
{block.items.map((item, idx) => {
|
{block.items.map((item, idx) => {
|
||||||
const key = `item-${idx}`;
|
const key = `item-${idx}`;
|
||||||
return (
|
return (
|
||||||
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.25 px-0.5 py-0.25">
|
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.5 px-0.5 py-0.25">
|
||||||
{renderLine(item, keymap, idx)}
|
{renderLine(item, keymap, idx)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -93,10 +93,10 @@ function ListBlock({ block, keymap }) {
|
|||||||
function CalloutBlock({ block }) {
|
function CalloutBlock({ block }) {
|
||||||
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
|
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
|
||||||
return (
|
return (
|
||||||
<div className={`surface space-y-0.25 border ${toneClass} px-0.5 py-0.5`}>
|
<div className={`surface space-y-0.5 border ${toneClass} px-0.5 py-0.5`}>
|
||||||
<p className="text-xs font-semibold text-slate-100">{block.title}</p>
|
<p className="text-xs font-semibold text-slate-100">{block.title}</p>
|
||||||
{block.body && (
|
{block.body && (
|
||||||
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
|
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
|
||||||
{block.body.map((line, idx) => (
|
{block.body.map((line, idx) => (
|
||||||
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
|
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
|
||||||
{renderLine(line, {})}
|
{renderLine(line, {})}
|
||||||
@@ -110,9 +110,9 @@ function CalloutBlock({ block }) {
|
|||||||
|
|
||||||
function KeyboardGroup({ group, keymap }) {
|
function KeyboardGroup({ group, keymap }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25 surface">
|
<div className="space-y-0.5 surface">
|
||||||
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
|
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
|
||||||
<div className="space-y-0.25 px-0.5 pb-0.25">
|
<div className="space-y-0.5 px-0.5 pb-0.25">
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
|
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
|
||||||
<span className="text-slate-200">{item.label}</span>
|
<span className="text-slate-200">{item.label}</span>
|
||||||
@@ -127,7 +127,7 @@ function KeyboardGroup({ group, keymap }) {
|
|||||||
function KeyboardBlock({ block, keymap }) {
|
function KeyboardBlock({ block, keymap }) {
|
||||||
if (!block) return null;
|
if (!block) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-200">
|
<div className="flex items-center justify-between text-xs text-slate-200">
|
||||||
<span className="font-semibold">{block.title}</span>
|
<span className="font-semibold">{block.title}</span>
|
||||||
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
|
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
|
||||||
@@ -144,9 +144,9 @@ function KeyboardBlock({ block, keymap }) {
|
|||||||
function GamepadBlock({ block }) {
|
function GamepadBlock({ block }) {
|
||||||
if (!block) return null;
|
if (!block) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
||||||
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
|
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
|
||||||
{block.items?.map((line, idx) => (
|
{block.items?.map((line, idx) => (
|
||||||
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
|
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
|
||||||
{renderLine(line, {})}
|
{renderLine(line, {})}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function HelpOverlay({ visible, layout, onClose, showOnLoad, onTo
|
|||||||
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.25 text-sm text-slate-200">
|
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.25 text-sm text-slate-200">
|
||||||
<span className="font-semibold">Help & controls</span>
|
<span className="font-semibold">Help & controls</span>
|
||||||
<div className="flex items-center gap-0.5 text-[0.8rem] text-slate-300">
|
<div className="flex items-center gap-0.5 text-[0.8rem] text-slate-300">
|
||||||
<label className="flex items-center gap-0.25">
|
<label className="flex items-center gap-0.5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!showOnLoad}
|
checked={!showOnLoad}
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ function EntityRow({ entity, connected, onToggle }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(entity.id)}
|
onClick={() => onToggle(entity.id)}
|
||||||
disabled={disableToggle}
|
disabled={disableToggle}
|
||||||
className={`flex min-w-[12rem] flex-1 items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
|
className={`flex min-w-[10rem] flex-1 items-center justify-between gap-0.5 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-0.5 text-sm">
|
||||||
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
|
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
|
||||||
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */}
|
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
<div className="flex items-center gap-0.5 text-xs text-slate-400">
|
||||||
<StatusBadge label={statusLabel} tone={statusTone} />
|
<StatusBadge label={statusLabel} tone={statusTone} />
|
||||||
{!connected && <span className="text-amber-200"> · Offline</span>}
|
{!connected && <span className="text-amber-200"> · Offline</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -84,10 +84,10 @@ export default function HomeAssistantControls() {
|
|||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section space-y-0.5 text-base">
|
||||||
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<p>Light Controls</p>
|
<p>Light Controls</p>
|
||||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
<span className="text-xs text-slate-500">{entities.length}</span>
|
||||||
<div className="flex items-center gap-1 text-xs text-slate-300 background-black">
|
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
<span>On</span>
|
<span>On</span>
|
||||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function SpeedField({ label, description, value, onChange, min = 0, max = 500, s
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
||||||
<div className="mt-0.5 flex items-center gap-0.5">
|
<div className="mt-0 flex items-center gap-0.5">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSession } from "../context/SessionContext";
|
import { useSession } from "../context/SessionContext";
|
||||||
import { FaCoffee } from "react-icons/fa";
|
import { FaCoffee } from "react-icons/fa";
|
||||||
|
|
||||||
export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
|
export default function KoFiButton({ text = "Support me on Ko-fi!", className = "" }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const kofiLink = session?.kofi?.link || null;
|
const kofiLink = session?.kofi?.link || null;
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
|
|||||||
href={kofiLink}
|
href={kofiLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center"
|
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center gap-1 ${className}`}
|
||||||
>
|
>
|
||||||
<FaCoffee className="mr-1" />
|
<FaCoffee className="mr-0" />
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ function MobileJoystickPanel({ layout }) {
|
|||||||
step={0.5}
|
step={0.5}
|
||||||
value={cameraValue}
|
value={cameraValue}
|
||||||
onChange={handleCameraSlider}
|
onChange={handleCameraSlider}
|
||||||
className="mt-0.5 w-full accent-cyan-400"
|
className="mt-0 w-full accent-cyan-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,16 +31,20 @@ export default function NicknameForm({ compact = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex w-full gap-0.5" onSubmit={handleSave}>
|
<form className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-stretch gap-0.5" onSubmit={handleSave}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1"
|
className="field-input flex-1 min-w-0"
|
||||||
value={nicknameInput}
|
value={nicknameInput}
|
||||||
onChange={(e) => setNicknameInput(e.target.value)}
|
onChange={(e) => setNicknameInput(e.target.value)}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
placeholder="Enter a nickname"
|
placeholder="Enter a nickname"
|
||||||
disabled={!canSetNickname}
|
disabled={!canSetNickname}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={!canSetNickname || saving} className="button-dark disabled:opacity-50">
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSetNickname || saving}
|
||||||
|
className="button-dark h-full shrink-0 whitespace-nowrap px-0.5 py-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useControlSystem } from '../controls/index.js';
|
||||||
|
import { OVERCURRENT_GROUPS } from '../controls/overcurrentLimiter.js';
|
||||||
|
|
||||||
|
const GROUP_LABELS = {
|
||||||
|
drive: 'Drive wheels',
|
||||||
|
aux: 'Aux motors',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPct(value) {
|
||||||
|
if (!Number.isFinite(value)) return '--';
|
||||||
|
return `${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
||||||
|
const width = `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
||||||
|
return (
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
|
||||||
|
<div className={`h-full ${color}`} style={{ width }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OvercurrentLimiterPanel() {
|
||||||
|
const {
|
||||||
|
state: { roverId },
|
||||||
|
overcurrentLimiter,
|
||||||
|
} = useControlSystem();
|
||||||
|
const groups = useMemo(() => OVERCURRENT_GROUPS.map((group) => group.key), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel-section space-y-0.5 text-sm">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Overcurrent limiter</span>
|
||||||
|
<span>{overcurrentLimiter?.adminImmune ? 'Admin immune' : 'Active'}</span>
|
||||||
|
</div>
|
||||||
|
{!roverId ? (
|
||||||
|
<p className="text-xs text-slate-500">Assign a rover to view limiter status.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{groups.map((key) => {
|
||||||
|
const cap = overcurrentLimiter?.caps?.[key]?.cap ?? 0;
|
||||||
|
const over = overcurrentLimiter?.overcurrent?.groups?.[key] ?? false;
|
||||||
|
const scale = overcurrentLimiter?.scales?.perGroup?.[key] ?? 1;
|
||||||
|
return (
|
||||||
|
<div key={key} className="surface space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-200">{GROUP_LABELS[key] || key}</span>
|
||||||
|
<span className={over ? 'text-red-300' : 'text-slate-400'}>
|
||||||
|
{over ? 'overcurrent' : 'ok'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||||
|
<span>Cap</span>
|
||||||
|
<span>{formatPct(cap)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={cap} color="bg-amber-500" />
|
||||||
|
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||||
|
<span>Scale</span>
|
||||||
|
<span>{formatPct(scale)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="surface text-[0.7rem] text-slate-400">
|
||||||
|
<div>{`Down rate ${overcurrentLimiter?.config?.downRatePerSec}/s · Up rate ${overcurrentLimiter?.config?.upRatePerSec}/s`}</div>
|
||||||
|
<div>{`Release delay ${overcurrentLimiter?.config?.releaseDelaySec}s`}</div>
|
||||||
|
<div>{`Output rate ${overcurrentLimiter?.config?.outputRateMs}ms`}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
|
import NicknameForm from './NicknameForm.jsx';
|
||||||
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
|
import KoFiButton from './KoFiButton.jsx';
|
||||||
|
|
||||||
|
function roleColors(role) {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
case 'lockdown':
|
||||||
|
case 'lockdown-admin':
|
||||||
|
return 'text-amber-300';
|
||||||
|
case 'spectator':
|
||||||
|
return 'text-slate-400';
|
||||||
|
default:
|
||||||
|
return 'text-sky-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(user, selfId) {
|
||||||
|
if (!user) return '';
|
||||||
|
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
|
||||||
|
if (user.socketId && user.socketId === selfId) {
|
||||||
|
return `${base} (you)`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RawUserPilePanel({
|
||||||
|
hideNicknameForm = false,
|
||||||
|
hideHeader = false,
|
||||||
|
className = '',
|
||||||
|
fillHeight = false,
|
||||||
|
compact = false,
|
||||||
|
}) {
|
||||||
|
const { session, setNickname } = useSession();
|
||||||
|
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
|
const lastSyncedSocketRef = useRef(null);
|
||||||
|
const socket = useSocket();
|
||||||
|
const canSetNickname = session?.role !== 'spectator';
|
||||||
|
const users = session?.users ?? [];
|
||||||
|
const selfId = session?.socketId || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canSetNickname) return;
|
||||||
|
if (!session?.socketId) return;
|
||||||
|
const nicknameInput = value.nickname || '';
|
||||||
|
if (!nicknameInput) return;
|
||||||
|
if (session.socketId === lastSyncedSocketRef.current) return;
|
||||||
|
const currentId = session.socketId;
|
||||||
|
setNickname(nicknameInput).then(() => {
|
||||||
|
lastSyncedSocketRef.current = currentId;
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [canSetNickname, session?.socketId, setNickname, value.nickname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return undefined;
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (!canSetNickname) return;
|
||||||
|
const nick = (value.nickname || '').trim();
|
||||||
|
if (!nick) return;
|
||||||
|
setNickname(nick).then(() => {
|
||||||
|
lastSyncedSocketRef.current = session?.socketId || null;
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
socket.on('connect', handleConnect);
|
||||||
|
return () => socket.off('connect', handleConnect);
|
||||||
|
}, [canSetNickname, setNickname, socket, value.nickname, session?.socketId]);
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...users].sort((a, b) => {
|
||||||
|
if (a.socketId === selfId) return -1;
|
||||||
|
if (b.socketId === selfId) return 1;
|
||||||
|
return (a.nickname || '').localeCompare(b.nickname || '');
|
||||||
|
}),
|
||||||
|
[selfId, users],
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseListClass = fillHeight
|
||||||
|
? 'flex-1 min-h-0 overflow-y-auto'
|
||||||
|
: compact
|
||||||
|
? 'h-28 overflow-y-auto'
|
||||||
|
: 'h-48 overflow-y-auto';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
|
||||||
|
>
|
||||||
|
{!hideNicknameForm && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="surface flex w-full items-center px-0 py-0">
|
||||||
|
<NicknameForm compact={compact} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-0.5 sm:grid-cols-2 md:grid-cols-1">
|
||||||
|
<DiscordInviteButton />
|
||||||
|
<KoFiButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
||||||
|
{!hideHeader && (
|
||||||
|
<div className={`flex items-center justify-between text-sm text-slate-400 ${compact ? 'text-xs' : ''}`}>
|
||||||
|
<span>Users</span>
|
||||||
|
<span className="text-xs text-slate-500">{sorted.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`surface flex flex-wrap content-start items-start gap-0.5 px-0 pb-0 ${baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}
|
||||||
|
>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Waiting for users…</p>
|
||||||
|
) : (
|
||||||
|
sorted.map((user) => (
|
||||||
|
<span
|
||||||
|
key={user.socketId}
|
||||||
|
className={`rounded bg-slate-800/80 px-1 py-0.25 text-[0.7rem] ${roleColors(user.role)}`}
|
||||||
|
>
|
||||||
|
{formatLabel(user, selfId)}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ function normalizeSources(list = []) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
|
||||||
const { session, triggerReplay } = useSession();
|
const { session, triggerReplay } = useSession();
|
||||||
const sources = normalizeSources(session?.replaySources || []);
|
const sources = normalizeSources(session?.replaySources || []);
|
||||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||||
@@ -100,17 +100,20 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const containerClass = fillHeight ? 'h-full flex flex-col' : '';
|
||||||
|
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section p-0.75 text-sm">
|
<section className={`panel-section p-0.75 text-sm ${containerClass}`}>
|
||||||
<header className="panel-muted flex items-center justify-between text-xs">
|
<header className="panel-muted flex items-center justify-between text-xs">
|
||||||
<span>Replay Sources</span>
|
<span>Replay Sources</span>
|
||||||
<span>{sources.length}</span>
|
<span>{sources.length}</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="grid gap-0.5 md:grid-cols-2">
|
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
|
||||||
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
||||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-dark w-full text-xs disabled:opacity-40"
|
className="button-dark w-full text-xs disabled:opacity-40"
|
||||||
@@ -129,9 +132,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
|||||||
function GroupList({ title, items, selected, onToggle }) {
|
function GroupList({ title, items, selected, onToggle }) {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<div className="panel-muted text-xs">{title}</div>
|
<div className="panel-muted text-xs">{title}</div>
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
|
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,10 +1,49 @@
|
|||||||
import TelemetryPanel from './TelemetryPanel.jsx';
|
import { InlineCameraTilt } from './ControlSummary.jsx';
|
||||||
import ControlSummary from './ControlSummary.jsx';
|
|
||||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||||
import SettingsPanel from './SettingsPanel.jsx';
|
import SettingsPanel from './SettingsPanel.jsx';
|
||||||
import HelpPanel from './HelpPanel.jsx';
|
import HelpPanel from './HelpPanel.jsx';
|
||||||
|
import ChatPanel from './ChatPanel.jsx';
|
||||||
|
import { LinkButtonsPanel, NicknameEntryPanel } from './UserListPanel.jsx';
|
||||||
|
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||||
|
import TopDownMap from './TopDownMap.jsx';
|
||||||
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
|
import { useControlSystem } from '../controls/index.js';
|
||||||
|
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
|
||||||
|
import RawUserPilePanel from './RawUserPilePanel.jsx';
|
||||||
|
|
||||||
|
function TopDownMapPanel() {
|
||||||
|
const {
|
||||||
|
state: { roverId },
|
||||||
|
} = useControlSystem();
|
||||||
|
const frame = useTelemetryFrame(roverId);
|
||||||
|
const sensors = frame?.sensors || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel-section">
|
||||||
|
<div className="aspect-square w-full">
|
||||||
|
<TopDownMap sensors={sensors} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriveDockPanel() {
|
||||||
|
const {
|
||||||
|
state: { roverId, keymap },
|
||||||
|
} = useControlSystem();
|
||||||
|
const driveDockState = useDriveDockState(roverId);
|
||||||
|
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel-section flex h-full flex-col gap-0.5">
|
||||||
|
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||||
|
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||||
return (
|
return (
|
||||||
@@ -18,10 +57,24 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel id="telemetry">
|
<TabPanel id="telemetry">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ControlSummary />
|
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)]">
|
||||||
|
<TopDownMapPanel />
|
||||||
|
<DriveDockPanel />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]">
|
||||||
|
<RoverQueuesPanel />
|
||||||
|
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||||
|
<LinkButtonsPanel />
|
||||||
|
</div>
|
||||||
|
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
|
||||||
|
<ChatPanel fillHeight />
|
||||||
|
<div className="grid min-h-0 gap-0.5 grid-rows-[minmax(0,1fr)_auto]">
|
||||||
|
<RawUserPilePanel compact hideNicknameForm fillHeight />
|
||||||
|
<NicknameEntryPanel compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<HomeAssistantControls />
|
<HomeAssistantControls />
|
||||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||||
<TelemetryPanel />
|
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="help">
|
<TabPanel id="help">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function RoomCameraFeed({ feed, label }) {
|
|||||||
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
|
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute bottom-0 left-0 m-0.5 flex items-center gap-1 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
|
<div className="pointer-events-none absolute bottom-0 left-0 m-0 flex items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
|
||||||
<span className={`h-2 w-2 rounded-full ${blink ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
<span className={`h-2 w-2 rounded-full ${blink ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
||||||
<span>{statusText}</span>
|
<span>{statusText}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ export default function RoomCameraPanel({
|
|||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section space-y-0.5 text-base">
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<p>Room cameras</p>
|
<p>Room cameras</p>
|
||||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
<div className="flex flex-wrap items-center gap-0.5 text-xs">
|
||||||
{showLayoutToggle && (
|
{showLayoutToggle && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-500">Layout:</span>
|
<span className="text-slate-500">Layout:</span>
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
function classNames(...values) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBattery(rover) {
|
||||||
|
const percent = rover?.batteryState?.percentDisplay;
|
||||||
|
if (percent == null) return '--';
|
||||||
|
return `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryClass(rover) {
|
||||||
|
if (!rover?.batteryState) return 'text-slate-400';
|
||||||
|
if (rover.batteryState.urgentActive) return 'text-red-400';
|
||||||
|
if (rover.batteryState.warnActive) return 'text-amber-300';
|
||||||
|
return 'text-emerald-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleColors(role) {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
case 'lockdown':
|
||||||
|
case 'lockdown-admin':
|
||||||
|
return 'text-amber-300';
|
||||||
|
case 'spectator':
|
||||||
|
return 'text-slate-400';
|
||||||
|
default:
|
||||||
|
return 'text-sky-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(user, selfId) {
|
||||||
|
if (!user) return '';
|
||||||
|
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
|
||||||
|
if (user.socketId && user.socketId === selfId) {
|
||||||
|
return `${base} (you)`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||||
|
const { session, requestControl } = useSession();
|
||||||
|
const [pending, setPending] = useState({});
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
|
||||||
|
const roster = session?.roster ?? [];
|
||||||
|
const turnQueues = session?.turnQueues ?? {};
|
||||||
|
const users = session?.users ?? [];
|
||||||
|
const selfId = session?.socketId || null;
|
||||||
|
const hasDeadlines = useMemo(
|
||||||
|
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
||||||
|
[turnQueues],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasDeadlines) return undefined;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [hasDeadlines]);
|
||||||
|
|
||||||
|
const rosterItems = useMemo(() => {
|
||||||
|
const known = new Set(roster.map((rover) => String(rover.id)));
|
||||||
|
const extra = Object.keys(turnQueues || {})
|
||||||
|
.filter((id) => !known.has(String(id)))
|
||||||
|
.map((id) => ({ id, name: id, locked: false, batteryState: null }));
|
||||||
|
return [...roster, ...extra];
|
||||||
|
}, [roster, turnQueues]);
|
||||||
|
|
||||||
|
async function handleRequest(targetRoverId) {
|
||||||
|
if (!targetRoverId) return;
|
||||||
|
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||||
|
try {
|
||||||
|
await requestControl(targetRoverId);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookupUser = (socketId) =>
|
||||||
|
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
{title && <p className="text-sm text-slate-400">{title}</p>}
|
||||||
|
{rosterItems.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5 text-sm">
|
||||||
|
{rosterItems.map((rover) => {
|
||||||
|
const roverId = String(rover.id);
|
||||||
|
const info = turnQueues?.[roverId] || null;
|
||||||
|
const queue = info?.queue || [];
|
||||||
|
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||||
|
const remainingSeconds =
|
||||||
|
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||||
|
const currentId = info?.current || null;
|
||||||
|
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||||
|
const nextId =
|
||||||
|
queue.length > 1
|
||||||
|
? currentIdx >= 0
|
||||||
|
? queue[(currentIdx + 1) % queue.length]
|
||||||
|
: queue[0]
|
||||||
|
: null;
|
||||||
|
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||||
|
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||||
|
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||||
|
const locked = Boolean(rover.locked);
|
||||||
|
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||||
|
const buttonLabel = locked ? lockLabel : pending[roverId] ? '...' : 'request';
|
||||||
|
const canClickRow = canRequest && !locked && !pending[roverId];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={rover.id}
|
||||||
|
className={classNames(
|
||||||
|
'surface flex flex-wrap items-start justify-between gap-0.5',
|
||||||
|
canClickRow && 'cursor-pointer',
|
||||||
|
locked && 'bg-red-900/40',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canClickRow) return;
|
||||||
|
handleRequest(rover.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<p className="text-slate-200">{rover.name}</p>
|
||||||
|
{showTimer ? (
|
||||||
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||||
|
{isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className={classNames('text-[0.75rem] font-semibold', batteryClass(rover))}>
|
||||||
|
{formatBattery(rover)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{queue.length === 0 ? (
|
||||||
|
<p className="text-[0.7rem] text-slate-500">No queue.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
|
{queue.map((socketId, idx) => {
|
||||||
|
const user = lookupUser(socketId);
|
||||||
|
const isCurrent = socketId === currentId;
|
||||||
|
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||||
|
const highlightClass = isCurrent
|
||||||
|
? 'bg-sky-600 text-white ring-2 ring-amber-300'
|
||||||
|
: isNext
|
||||||
|
? 'bg-emerald-700/60 text-emerald-100 ring-1 ring-emerald-300/70'
|
||||||
|
: 'bg-slate-800 text-slate-200';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${roverId}-${socketId}-${idx}`}
|
||||||
|
className={`flex items-center gap-0.5 rounded px-1 text-[0.7rem] ${highlightClass}`}
|
||||||
|
>
|
||||||
|
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||||
|
{formatLabel(user, selfId)}
|
||||||
|
</span>
|
||||||
|
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
||||||
|
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canRequest ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleRequest(rover.id);
|
||||||
|
}}
|
||||||
|
disabled={pending[roverId] || locked}
|
||||||
|
className={classNames(
|
||||||
|
'button-dark disabled:opacity-40',
|
||||||
|
locked && 'bg-red-600/70 text-white hover:bg-red-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export default function RoverRoster({
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-200">{rover.name}</p>
|
<p className="text-slate-200">{rover.name}</p>
|
||||||
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-1">
|
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-0.5">
|
||||||
<span>{rover.locked ? 'locked' : 'free'}</span>
|
<span>{rover.locked ? 'locked' : 'free'}</span>
|
||||||
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
||||||
<span className={classNames('font-semibold', batteryClass(rover))}>
|
<span className={classNames('font-semibold', batteryClass(rover))}>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ export default function SessionSnapshot() {
|
|||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
||||||
return (
|
return (
|
||||||
<div className="panel-section space-y-0.5 text-xs">
|
<div className="panel-section flex min-h-0 flex-col gap-0.5 text-xs">
|
||||||
<p className="text-sm text-slate-400">Session snapshot</p>
|
<p className="text-sm text-slate-400">Session snapshot</p>
|
||||||
<pre className="surface h-64 overflow-y-auto font-mono text-[0.7rem] text-lime-300">{payload}</pre>
|
<pre className="surface min-h-0 flex-1 overflow-y-auto font-mono text-[0.7rem] text-lime-300">
|
||||||
|
{payload}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AuthPanel from './AuthPanel.jsx';
|
|||||||
import AdminPanel from './AdminPanel.jsx';
|
import AdminPanel from './AdminPanel.jsx';
|
||||||
import KeymapSettings from './KeymapSettings.jsx';
|
import KeymapSettings from './KeymapSettings.jsx';
|
||||||
import GamepadMappingSettings from './GamepadMappingSettings.jsx';
|
import GamepadMappingSettings from './GamepadMappingSettings.jsx';
|
||||||
|
import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx';
|
||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||||
import SessionSnapshot from './SessionSnapshot.jsx';
|
import SessionSnapshot from './SessionSnapshot.jsx';
|
||||||
import SocketLogPanel from './SocketLogPanel.jsx';
|
import SocketLogPanel from './SocketLogPanel.jsx';
|
||||||
@@ -141,6 +142,7 @@ export default function SettingsPanel() {
|
|||||||
{!canControl && <p className="text-xs text-slate-500">Assign a rover to toggle streams.</p>}
|
{!canControl && <p className="text-xs text-slate-500">Assign a rover to toggle streams.</p>}
|
||||||
</section>
|
</section>
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
|
<OvercurrentLimiterPanel />
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
<SessionSnapshot />
|
<SessionSnapshot />
|
||||||
<SocketLogPanel />
|
<SocketLogPanel />
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function Tab({ id, children, className = '', disabled = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TabPanels({ children, className = '' }) {
|
export function TabPanels({ children, className = '' }) {
|
||||||
return <div className={classNames('mt-0.5 space-y-0.5', className)}>{children}</div>;
|
return <div className={classNames('mt-0 space-y-0.5', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabPanel({ id, children, keepMounted = false }) {
|
export function TabPanel({ id, children, keepMounted = false }) {
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ export default function TelemetryPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
||||||
<div className="text-sm text-slate-400">
|
{/* <div className="text-sm text-slate-400">
|
||||||
<span>{connected ? 'online' : 'offline'}</span>
|
<span>{connected ? 'online' : 'offline'}</span>
|
||||||
<span> · role {session?.role || 'unknown'}</span>
|
<span> · role {session?.role || 'unknown'}</span>
|
||||||
<span> · mode {session?.mode || '--'}</span>
|
<span> · mode {session?.mode || '--'}</span>
|
||||||
{updated && <span> · sensors {updated}</span>}
|
{updated && <span> · sensors {updated}</span>}
|
||||||
<span> · driver {driverLabel}</span>
|
<span> · driver {driverLabel}</span>
|
||||||
</div>
|
</div> */}
|
||||||
{!roverId ? (
|
{!roverId ? (
|
||||||
<p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
|
<p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
|
||||||
) : !frame ? (
|
) : !frame ? (
|
||||||
@@ -57,7 +57,7 @@ export default function TelemetryPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capacity }) {
|
function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capacity }) {
|
||||||
const chargePct = charge != null && capacity ? `${Math.round((charge / capacity) * 100)}%` : '--';
|
const chargePct = charge != null && capacity ? `${Math.round((charge / capacity) * 100)}` : '--';
|
||||||
const oiMode = sensors?.oiMode?.label || '--';
|
const oiMode = sensors?.oiMode?.label || '--';
|
||||||
const docked = sensors?.chargingSources?.homeBase ? 'Yes' : 'No';
|
const docked = sensors?.chargingSources?.homeBase ? 'Yes' : 'No';
|
||||||
const charging = sensors?.chargingState?.label || '--';
|
const charging = sensors?.chargingState?.label || '--';
|
||||||
@@ -69,7 +69,7 @@ function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capa
|
|||||||
<Metric label="Battery temp" value={batteryTemp ?? '--'} />
|
<Metric label="Battery temp" value={batteryTemp ?? '--'} />
|
||||||
<Metric label="Charge" value={charge != null ? `${charge} mAh` : '--'} />
|
<Metric label="Charge" value={charge != null ? `${charge} mAh` : '--'} />
|
||||||
<Metric label="Capacity" value={capacity != null ? `${capacity} mAh` : '--'} />
|
<Metric label="Capacity" value={capacity != null ? `${capacity} mAh` : '--'} />
|
||||||
<Metric label="Charge %" value={chargePct} />
|
<Metric label="Charge" value={chargePct} />
|
||||||
<Metric label="OI mode" value={oiMode} />
|
<Metric label="OI mode" value={oiMode} />
|
||||||
<Metric label="Docked" value={docked} />
|
<Metric label="Docked" value={docked} />
|
||||||
<Metric label="Charging state" value={charging} />
|
<Metric label="Charging state" value={charging} />
|
||||||
@@ -173,7 +173,7 @@ function DetailCard({ title, children }) {
|
|||||||
|
|
||||||
function ValueRow({ label, value }) {
|
function ValueRow({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
<span className="text-slate-300">{label}</span>
|
<span className="text-slate-300">{label}</span>
|
||||||
<span className="text-slate-100">{value}</span>
|
<span className="text-slate-100">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,16 +212,16 @@ function DockMiniStatus({ sensors }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-1 text-xs text-slate-200">
|
<div className="flex items-center justify-between gap-0.5 text-xs text-slate-200">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-400">L</span>
|
<span className="text-slate-400">L</span>
|
||||||
{badge(left)}
|
{badge(left)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-400">O</span>
|
<span className="text-slate-400">O</span>
|
||||||
{badge(omni)}
|
{badge(omni)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-400">R</span>
|
<span className="text-slate-400">R</span>
|
||||||
{badge(right)}
|
{badge(right)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import NicknameForm from './NicknameForm.jsx';
|
|||||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
import KoFiButton from './KoFiButton.jsx';
|
import KoFiButton from './KoFiButton.jsx';
|
||||||
|
|
||||||
|
export function NicknameEntryPanel({ compact = false }) {
|
||||||
|
return (
|
||||||
|
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
||||||
|
<div className="surface flex w-full items-center px-0 py-0">
|
||||||
|
<NicknameForm compact={compact} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkButtonsPanel() {
|
||||||
|
return (
|
||||||
|
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
||||||
|
<div className="grid flex-1 min-h-0 gap-0.5 grid-rows-2">
|
||||||
|
<DiscordInviteButton className="h-full" />
|
||||||
|
<KoFiButton className="h-full" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function roleColors(role) {
|
function roleColors(role) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
@@ -28,7 +49,14 @@ function formatLabel(user, selfId) {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserListPanel({ hideNicknameForm = false, hideHeader = false, className = '', fillHeight = false }) {
|
export default function UserListPanel({
|
||||||
|
hideNicknameForm = false,
|
||||||
|
hideHeader = false,
|
||||||
|
className = '',
|
||||||
|
fillHeight = false,
|
||||||
|
compact = false,
|
||||||
|
showBothTurnsAndUsers = false,
|
||||||
|
}) {
|
||||||
const { session, setNickname } = useSession();
|
const { session, setNickname } = useSession();
|
||||||
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
const lastSyncedSocketRef = useRef(null);
|
const lastSyncedSocketRef = useRef(null);
|
||||||
@@ -39,6 +67,12 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
const isTurnsMode = session?.mode === 'turns';
|
const isTurnsMode = session?.mode === 'turns';
|
||||||
const turnQueues = session?.turnQueues || {};
|
const turnQueues = session?.turnQueues || {};
|
||||||
const roster = session?.roster || [];
|
const roster = session?.roster || [];
|
||||||
|
const [turnView, setTurnView] = useState('queues');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTurnsMode) return;
|
||||||
|
setTurnView('queues');
|
||||||
|
}, [isTurnsMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canSetNickname) return;
|
if (!canSetNickname) return;
|
||||||
@@ -93,9 +127,23 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
return Math.ceil(ms / 1000);
|
return Math.ceil(ms / 1000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const baseListClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
const baseListClass = fillHeight
|
||||||
const turnsListClass = isTurnsMode && fillHeight ? 'max-h-40 overflow-y-auto' : baseListClass;
|
? 'flex-1 min-h-0 overflow-y-auto'
|
||||||
const usersListClass = isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
|
: compact
|
||||||
|
? 'h-28 overflow-y-auto'
|
||||||
|
: 'h-48 overflow-y-auto';
|
||||||
|
const turnsListClass =
|
||||||
|
isTurnsMode && fillHeight
|
||||||
|
? 'max-h-40 overflow-y-auto'
|
||||||
|
: isTurnsMode && compact
|
||||||
|
? 'max-h-32 overflow-y-auto'
|
||||||
|
: baseListClass;
|
||||||
|
const usersListClass =
|
||||||
|
isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
|
||||||
|
const showToggle = isTurnsMode && !showBothTurnsAndUsers;
|
||||||
|
const showQueuesSection = isTurnsMode && (showBothTurnsAndUsers || turnView === 'queues');
|
||||||
|
const showUsersSection = !isTurnsMode || (showToggle && turnView === 'users');
|
||||||
|
const showUsersSecondary = isTurnsMode && showBothTurnsAndUsers;
|
||||||
|
|
||||||
const renderUserList = () =>
|
const renderUserList = () =>
|
||||||
sorted.length === 0 ? (
|
sorted.length === 0 ? (
|
||||||
@@ -107,7 +155,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.socketId}
|
key={user.socketId}
|
||||||
className="surface-muted flex items-center gap-1 text-sm"
|
className={`surface-muted flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
|
||||||
>
|
>
|
||||||
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
||||||
{user.roverId ? (
|
{user.roverId ? (
|
||||||
@@ -131,13 +179,13 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
>
|
>
|
||||||
{!hideNicknameForm && (
|
{!hideNicknameForm && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-stretch gap-0.5">
|
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
<div className="flex w-1/2 min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="surface flex w-full items-center">
|
<div className="surface flex w-full items-center px-0 py-0">
|
||||||
<NicknameForm />
|
<NicknameForm compact={compact} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-1/2 flex-col gap-0.5">
|
<div className="grid gap-0.5 sm:grid-cols-2 md:grid-cols-1">
|
||||||
<DiscordInviteButton />
|
<DiscordInviteButton />
|
||||||
<KoFiButton />
|
<KoFiButton />
|
||||||
</div>
|
</div>
|
||||||
@@ -148,16 +196,42 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
|
|
||||||
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
<div className={`flex items-center justify-between text-sm text-slate-400 ${compact ? 'text-xs' : ''}`}>
|
||||||
<span>{isTurnsMode ? 'Turn queues' : 'Users'}</span>
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-xs text-slate-500">
|
<span>
|
||||||
{isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
{isTurnsMode
|
||||||
</span>
|
? showQueuesSection
|
||||||
|
? 'Turn queues'
|
||||||
|
: 'Users'
|
||||||
|
: 'Users'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{showQueuesSection && isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showToggle ? (
|
||||||
|
<div className="inline-flex overflow-hidden rounded border border-slate-700 text-[0.7rem]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-1 py-0.5 ${turnView === 'queues' ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||||
|
onClick={() => setTurnView('queues')}
|
||||||
|
>
|
||||||
|
Queues
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-1 py-0.5 ${turnView === 'users' ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||||
|
onClick={() => setTurnView('users')}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`surface space-y-0.25 ${isTurnsMode ? turnsListClass : baseListClass}`}>
|
{showQueuesSection ? (
|
||||||
{isTurnsMode ? (
|
<div className={`surface space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||||
Object.keys(turnQueues || {}).length === 0 ? (
|
{Object.keys(turnQueues || {}).length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(turnQueues).map(([roverId, info]) => {
|
Object.entries(turnQueues).map(([roverId, info]) => {
|
||||||
@@ -173,8 +247,8 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
: queue[0]
|
: queue[0]
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={roverId} className="surface-muted flex flex-col gap-0.25 text-sm">
|
<div key={roverId} className={`surface-muted flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
||||||
{remaining != null && (
|
{remaining != null && (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
||||||
@@ -185,7 +259,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
{queue.length === 0 ? (
|
{queue.length === 0 ? (
|
||||||
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
|
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{queue.map((socketId, idx) => {
|
{queue.map((socketId, idx) => {
|
||||||
const user = lookupUser(socketId);
|
const user = lookupUser(socketId);
|
||||||
const isCurrent = socketId === currentId;
|
const isCurrent = socketId === currentId;
|
||||||
@@ -201,7 +275,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={`${roverId}-${socketId}-${idx}`}
|
key={`${roverId}-${socketId}-${idx}`}
|
||||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.8rem] ${highlightClass}`}
|
className={`flex items-center gap-0.5 rounded px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
||||||
>
|
>
|
||||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||||
{formatLabel(user, selfId)}
|
{formatLabel(user, selfId)}
|
||||||
@@ -218,21 +292,23 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
renderUserList()
|
) : null}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isTurnsMode ? (
|
{showUsersSection ? (
|
||||||
<div className={`space-y-0.25 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
|
<div className={`surface space-y-0.5 px-0 pb-0 ${usersListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||||
|
{renderUserList()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showUsersSecondary ? (
|
||||||
|
<div className={`space-y-0.5 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
|
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`surface space-y-0.25 ${usersListClass}`}>
|
<div className={`surface space-y-0.5 px-0 pb-0 ${usersListClass}`}>{renderUserList()}</div>
|
||||||
{renderUserList()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
|||||||
import { useChat } from '../context/ChatContext.jsx';
|
import { useChat } from '../context/ChatContext.jsx';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
|
|
||||||
const RESTART_DELAY_MS = 2000;
|
const RESTART_DELAY_MS = 2000;
|
||||||
const UNMUTE_RETRY_MS = 3000;
|
const UNMUTE_RETRY_MS = 3000;
|
||||||
@@ -51,7 +52,9 @@ export default function VideoTile({
|
|||||||
driverLabel = null,
|
driverLabel = null,
|
||||||
hudForceMap = false,
|
hudForceMap = false,
|
||||||
hudMapPosition = 'top-right',
|
hudMapPosition = 'top-right',
|
||||||
|
hudLabelScale = 1,
|
||||||
fitParent = false,
|
fitParent = false,
|
||||||
|
overcurrentLimiter = null,
|
||||||
showTurnCue = false,
|
showTurnCue = false,
|
||||||
turnTimerText = null,
|
turnTimerText = null,
|
||||||
turnSeconds = null,
|
turnSeconds = null,
|
||||||
@@ -92,6 +95,33 @@ export default function VideoTile({
|
|||||||
: Object.entries(wheelOvercurrents)
|
: Object.entries(wheelOvercurrents)
|
||||||
.filter(([, active]) => Boolean(active))
|
.filter(([, active]) => Boolean(active))
|
||||||
.map(([key]) => key);
|
.map(([key]) => key);
|
||||||
|
const limiterCaps = overcurrentLimiter?.caps || null;
|
||||||
|
const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null;
|
||||||
|
const debugHud =
|
||||||
|
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugHud');
|
||||||
|
const limiterFill = useMemo(() => {
|
||||||
|
if (!limiterCaps) return null;
|
||||||
|
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
|
||||||
|
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
|
||||||
|
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
|
||||||
|
}, [limiterCaps]);
|
||||||
|
const limiterActive = Boolean(overcurrentLimiter?.isActive);
|
||||||
|
const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
|
||||||
|
const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
||||||
|
const overlayVisible = Boolean(overlayMotors.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debugHud) return;
|
||||||
|
console.log('[OvercurrentHUD]', {
|
||||||
|
overlayVisible,
|
||||||
|
overlayMotors,
|
||||||
|
overlayFill,
|
||||||
|
limiterActive,
|
||||||
|
limiterCaps,
|
||||||
|
limiterGroups,
|
||||||
|
wheelOvercurrents,
|
||||||
|
});
|
||||||
|
}, [debugHud, overlayFill, overlayMotors, overlayVisible, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
|
||||||
|
|
||||||
const scheduleRestart = useCallback(() => {
|
const scheduleRestart = useCallback(() => {
|
||||||
clearTimeout(restartTimer.current);
|
clearTimeout(restartTimer.current);
|
||||||
@@ -366,7 +396,7 @@ export default function VideoTile({
|
|||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||||
<div
|
<div
|
||||||
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-video'}`}
|
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}
|
||||||
>
|
>
|
||||||
{usingSnapshot ? (
|
{usingSnapshot ? (
|
||||||
snapshotFeed?.objectUrl ? (
|
snapshotFeed?.objectUrl ? (
|
||||||
@@ -415,9 +445,15 @@ export default function VideoTile({
|
|||||||
mobileHud={mobileHud}
|
mobileHud={mobileHud}
|
||||||
mapPosition={hudMapPosition}
|
mapPosition={hudMapPosition}
|
||||||
turnTimerText={turnTimerText}
|
turnTimerText={turnTimerText}
|
||||||
|
labelScale={hudLabelScale}
|
||||||
/>
|
/>
|
||||||
<HudChatInput compact={mobileHud} />
|
<HudChatInput compact={mobileHud} />
|
||||||
<OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
|
{debugHud ? (
|
||||||
|
<div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200">
|
||||||
|
{`OC vis:${overlayVisible ? 1 : 0} motors:${overlayMotors.length} fill:${Math.round(overlayFill * 100)}%`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} />
|
||||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} />
|
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} />
|
||||||
{showVerticalBattery && batteryVisual.available ? (
|
{showVerticalBattery && batteryVisual.available ? (
|
||||||
<BatteryBarVertical visual={batteryVisual} />
|
<BatteryBarVertical visual={batteryVisual} />
|
||||||
@@ -429,13 +465,16 @@ export default function VideoTile({
|
|||||||
mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
|
mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{qualityNotice}
|
<div className="text-center">{qualityNotice}</div>
|
||||||
|
<div className="pointer-events-auto mt-0">
|
||||||
|
<DiscordInviteButton text={'Join our Discord server while you wait!'} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!showVerticalBattery && (
|
{!showVerticalBattery && (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<LightBumpBars sensors={sensors} />
|
<LightBumpBars sensors={sensors} />
|
||||||
<BatteryBar visual={batteryVisual} />
|
<BatteryBar visual={batteryVisual} />
|
||||||
</div>
|
</div>
|
||||||
@@ -478,7 +517,7 @@ function BatteryBarVertical({ visual }) {
|
|||||||
style={{ height: `${visual.percentDisplay}%` }}
|
style={{ height: `${visual.percentDisplay}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-0.5 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
|
<span className="mt-0 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -504,7 +543,7 @@ function LightBumpBars({ sensors }) {
|
|||||||
const barHeight = 12;
|
const barHeight = 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center gap-1">
|
<div className="flex w-full items-center justify-center gap-0.5">
|
||||||
{values.map((v, idx) => {
|
{values.map((v, idx) => {
|
||||||
const t = eased(v);
|
const t = eased(v);
|
||||||
const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right
|
const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right
|
||||||
@@ -544,6 +583,7 @@ function HudOverlay({
|
|||||||
mobileHud = false,
|
mobileHud = false,
|
||||||
mapPosition = 'top-right',
|
mapPosition = 'top-right',
|
||||||
turnTimerText = null,
|
turnTimerText = null,
|
||||||
|
labelScale = 1,
|
||||||
}) {
|
}) {
|
||||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
@@ -565,6 +605,10 @@ function HudOverlay({
|
|||||||
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
||||||
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
||||||
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
|
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
|
||||||
|
const labelWrapperStyle = {
|
||||||
|
transform: `translateX(-50%) scale(${labelScale})`,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
};
|
||||||
const mapSize = '240px';
|
const mapSize = '240px';
|
||||||
const mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7;
|
const mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7;
|
||||||
const mapOpacity = isMobile ? 0.85 : 0.7;
|
const mapOpacity = isMobile ? 0.85 : 0.7;
|
||||||
@@ -589,15 +633,15 @@ function HudOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
<div className="flex flex-col gap-[1px] leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span>Status: {status}</span>
|
<span>Status: {status}</span>
|
||||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.35 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.1 leading-tight">
|
<div className="space-y-0.5 leading-tight">
|
||||||
<span className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
|
<span className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
|
||||||
Telemetry
|
Telemetry
|
||||||
</span>
|
</span>
|
||||||
@@ -626,11 +670,13 @@ function HudOverlay({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
<div
|
||||||
>
|
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
||||||
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
|
>
|
||||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
|
||||||
|
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -639,7 +685,7 @@ function HudOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
<div className="flex flex-col gap-[1px] leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span>Status: {status}</span>
|
<span>Status: {status}</span>
|
||||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -651,11 +697,11 @@ function HudOverlay({
|
|||||||
{turnTimerText}
|
{turnTimerText}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
||||||
>
|
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
||||||
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTopDown && variant !== 'spectator' ? (
|
{showTopDown && variant !== 'spectator' ? (
|
||||||
@@ -687,7 +733,7 @@ function TurnCueOverlay({
|
|||||||
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
||||||
<div className={`flex flex-col items-center gap-1 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
|
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
|
||||||
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
||||||
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
||||||
{showCountdown ? (
|
{showCountdown ? (
|
||||||
@@ -705,21 +751,30 @@ const OVERCURRENT_LABELS = {
|
|||||||
rightWheel: 'Right wheel',
|
rightWheel: 'Right wheel',
|
||||||
mainBrush: 'Main brush',
|
mainBrush: 'Main brush',
|
||||||
sideBrush: 'Side brush',
|
sideBrush: 'Side brush',
|
||||||
|
limiter: 'Overcurrent limit',
|
||||||
};
|
};
|
||||||
|
|
||||||
function OvercurrentOverlay({ motors, compact = false }) {
|
function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
|
||||||
if (!motors?.length) return null;
|
if (!motors?.length) return null;
|
||||||
const labels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||||
const containerClass = compact ? 'p-2' : 'p-4';
|
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
||||||
|
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
||||||
const textClass = compact ? 'text-lg' : 'text-4xl';
|
const textClass = compact ? 'text-lg' : 'text-4xl';
|
||||||
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
||||||
|
const safeFill = Math.max(0, Math.min(1, fill));
|
||||||
|
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/60 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
||||||
>
|
>
|
||||||
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
<div className="relative h-full w-full">
|
||||||
<div>OVERCURRENT</div>
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div className={`mt-0.5 font-medium text-white ${subTextClass}`}>{labels.join(', ')}</div>
|
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
||||||
|
</div>
|
||||||
|
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
||||||
|
<div>OVERCURRENT</div>
|
||||||
|
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -757,7 +812,7 @@ function LowBatteryOverlay({ charge, config, compact = false }) {
|
|||||||
|
|
||||||
function HudChatInput({ compact = false }) {
|
function HudChatInput({ compact = false }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef } = useChat();
|
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
|
||||||
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
|
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@@ -783,7 +838,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
return { speak: true, engine, voice };
|
return { speak: true, engine, voice };
|
||||||
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
|
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
|
||||||
const containerClass = compact
|
const containerClass = compact
|
||||||
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.25 rounded bg-black/70 px-0.4 py-0.2'
|
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
|
||||||
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
|
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
|
||||||
const inputClass = compact
|
const inputClass = compact
|
||||||
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
|
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
|
||||||
@@ -802,6 +857,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
await sendMessage(clean, ttsPayload);
|
await sendMessage(clean, ttsPayload);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
blurChat();
|
blurChat();
|
||||||
|
setTypingActive(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -816,17 +872,28 @@ function HudChatInput({ compact = false }) {
|
|||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => {
|
||||||
onFocus={onInputFocus}
|
const next = event.target.value;
|
||||||
onBlur={onInputBlur}
|
setDraft(next);
|
||||||
|
setTypingActive(Boolean(next.trim()));
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
onInputFocus(event);
|
||||||
|
setTypingActive(Boolean(draft.trim()));
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
onInputBlur(event);
|
||||||
|
setTypingActive(false);
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' && !draft.trim()) {
|
if (event.key === 'Enter' && !draft.trim()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
blurChat();
|
blurChat();
|
||||||
|
setTypingActive(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={(el) => registerInputRef(el, { target: 'hud' })}
|
ref={(el) => registerInputRef(el, { target: 'hud' })}
|
||||||
placeholder={canChat ? 'Chat…' : 'Spectator'}
|
placeholder={canChat ? 'Chat (TTS)' : 'Spectator'}
|
||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -834,7 +901,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
disabled={!canChat || sending}
|
disabled={!canChat || sending}
|
||||||
className={buttonClass}
|
className={buttonClass}
|
||||||
>
|
>
|
||||||
Send
|
Speak
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function DriveModeToggle({ size = 'default' }) {
|
|||||||
{currentLabel}
|
{currentLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 grid grid-cols-2 gap-0.5 text-xs">
|
<div className="mt-0 grid grid-cols-2 gap-0.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDrive}
|
onClick={handleDrive}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import messageSound from '../assets/message.mp3';
|
|||||||
|
|
||||||
const ChatContext = createContext({
|
const ChatContext = createContext({
|
||||||
messages: [],
|
messages: [],
|
||||||
|
typing: [],
|
||||||
sendMessage: async () => {},
|
sendMessage: async () => {},
|
||||||
focusChat: () => {},
|
focusChat: () => {},
|
||||||
blurChat: () => {},
|
blurChat: () => {},
|
||||||
registerInputRef: () => {},
|
registerInputRef: () => {},
|
||||||
onInputFocus: () => {},
|
onInputFocus: () => {},
|
||||||
onInputBlur: () => {},
|
onInputBlur: () => {},
|
||||||
|
setTypingActive: () => {},
|
||||||
isChatFocused: false,
|
isChatFocused: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,10 +22,30 @@ export function ChatProvider({ children }) {
|
|||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const { session, pushAlert } = useSession();
|
const { session, pushAlert } = useSession();
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [typing, setTyping] = useState([]);
|
||||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||||
const panelInputRef = useRef(null);
|
const panelInputRef = useRef(null);
|
||||||
const hudInputRef = useRef(null);
|
const hudInputRef = useRef(null);
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
|
const typingRef = useRef(new Map());
|
||||||
|
const typingAlertRef = useRef(new Map());
|
||||||
|
const typingStateRef = useRef({ isTyping: false, lastSent: 0 });
|
||||||
|
|
||||||
|
const rebuildTyping = useCallback(() => {
|
||||||
|
const entries = Array.from(typingRef.current.values())
|
||||||
|
.sort((a, b) => a.lastUpdate - b.lastUpdate)
|
||||||
|
.map((entry) => entry.payload);
|
||||||
|
setTyping(entries);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveTypingKey = useCallback((payload) => {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
if (payload.typingId) return payload.typingId;
|
||||||
|
if (payload.fromDiscord) {
|
||||||
|
return `discord:${payload.discordUserId || payload.discordUserName || payload.nickname || 'unknown'}`;
|
||||||
|
}
|
||||||
|
return `socket:${payload.socketId || payload.nickname || 'unknown'}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
audioRef.current = new Audio(messageSound);
|
audioRef.current = new Audio(messageSound);
|
||||||
@@ -57,6 +79,62 @@ export function ChatProvider({ children }) {
|
|||||||
};
|
};
|
||||||
}, [playSound, session?.socketId, socket]);
|
}, [playSound, session?.socketId, socket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleTyping(payload = {}) {
|
||||||
|
const key = resolveTypingKey(payload);
|
||||||
|
if (!key) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (payload?.socketId && session?.socketId && payload.socketId === session.socketId) {
|
||||||
|
if (!payload.isTyping) {
|
||||||
|
typingRef.current.delete(key);
|
||||||
|
rebuildTyping();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.isTyping) {
|
||||||
|
typingRef.current.set(key, {
|
||||||
|
payload,
|
||||||
|
expiresAt: now + 6000,
|
||||||
|
lastUpdate: now,
|
||||||
|
});
|
||||||
|
const lastAlertAt = typingAlertRef.current.get(key) || 0;
|
||||||
|
if (now - lastAlertAt >= 2500) {
|
||||||
|
typingAlertRef.current.set(key, now);
|
||||||
|
pushAlert?.({
|
||||||
|
kind: 'chat-typing',
|
||||||
|
payload,
|
||||||
|
id: `chat-typing-${key}-${payload.id || Math.random().toString(36).slice(2)}`,
|
||||||
|
receivedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
typingRef.current.delete(key);
|
||||||
|
}
|
||||||
|
rebuildTyping();
|
||||||
|
}
|
||||||
|
socket.on('chat:typing', handleTyping);
|
||||||
|
return () => {
|
||||||
|
socket.off('chat:typing', handleTyping);
|
||||||
|
};
|
||||||
|
}, [pushAlert, rebuildTyping, resolveTypingKey, session?.socketId, socket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
let changed = false;
|
||||||
|
typingRef.current.forEach((entry, key) => {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
typingRef.current.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
rebuildTyping();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [rebuildTyping]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleInit(payload = []) {
|
function handleInit(payload = []) {
|
||||||
if (!Array.isArray(payload)) return;
|
if (!Array.isArray(payload)) return;
|
||||||
@@ -75,6 +153,21 @@ export function ChatProvider({ children }) {
|
|||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
|
const setTypingActive = useCallback(
|
||||||
|
(next) => {
|
||||||
|
const isTyping = Boolean(next);
|
||||||
|
const now = Date.now();
|
||||||
|
const last = typingStateRef.current;
|
||||||
|
const shouldSendStop = !isTyping && last.isTyping;
|
||||||
|
const shouldSendStart =
|
||||||
|
isTyping && (!last.isTyping || now - last.lastSent >= 3500);
|
||||||
|
if (!shouldSendStart && !shouldSendStop) return;
|
||||||
|
typingStateRef.current = { isTyping, lastSent: now };
|
||||||
|
socket.emit('chat:typing', { isTyping });
|
||||||
|
},
|
||||||
|
[socket],
|
||||||
|
);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
(text, tts = null) =>
|
(text, tts = null) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
@@ -121,10 +214,12 @@ export function ChatProvider({ children }) {
|
|||||||
registerInputRef,
|
registerInputRef,
|
||||||
onInputFocus,
|
onInputFocus,
|
||||||
onInputBlur,
|
onInputBlur,
|
||||||
|
setTypingActive,
|
||||||
|
typing,
|
||||||
isChatFocused,
|
isChatFocused,
|
||||||
selfSocketId: session?.socketId || null,
|
selfSocketId: session?.socketId || null,
|
||||||
}),
|
}),
|
||||||
[blurChat, focusChat, isChatFocused, messages, onInputBlur, onInputFocus, registerInputRef, sendMessage, session?.socketId],
|
[blurChat, focusChat, isChatFocused, messages, onInputBlur, onInputFocus, registerInputRef, sendMessage, session?.socketId, setTypingActive, typing],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const SessionContext = createContext({
|
|||||||
connected: false,
|
connected: false,
|
||||||
session: null,
|
session: null,
|
||||||
logs: [],
|
logs: [],
|
||||||
|
adminLogs: [],
|
||||||
|
banStatus: null,
|
||||||
|
moderation: null,
|
||||||
login: async () => {},
|
login: async () => {},
|
||||||
setRole: async () => {},
|
setRole: async () => {},
|
||||||
requestControl: async () => {},
|
requestControl: async () => {},
|
||||||
@@ -17,6 +20,9 @@ const SessionContext = createContext({
|
|||||||
setNickname: async () => {},
|
setNickname: async () => {},
|
||||||
triggerReplay: async () => {},
|
triggerReplay: async () => {},
|
||||||
setCommunityGoal: async () => {},
|
setCommunityGoal: async () => {},
|
||||||
|
banUser: async () => {},
|
||||||
|
timeoutUser: async () => {},
|
||||||
|
unbanUser: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
function useAckEmitter(socket) {
|
function useAckEmitter(socket) {
|
||||||
@@ -40,6 +46,9 @@ export function SessionProvider({ children }) {
|
|||||||
const emitWithAck = useAckEmitter(socket);
|
const emitWithAck = useAckEmitter(socket);
|
||||||
const [session, setSession] = useState(null);
|
const [session, setSession] = useState(null);
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [adminLogs, setAdminLogs] = useState([]);
|
||||||
|
const [banStatus, setBanStatus] = useState(null);
|
||||||
|
const [moderation, setModeration] = useState(null);
|
||||||
const [alerts, setAlerts] = useState([]);
|
const [alerts, setAlerts] = useState([]);
|
||||||
const [connected, setConnected] = useState(socket.connected);
|
const [connected, setConnected] = useState(socket.connected);
|
||||||
|
|
||||||
@@ -64,9 +73,35 @@ export function SessionProvider({ children }) {
|
|||||||
function handleLogEntry(entry) {
|
function handleLogEntry(entry) {
|
||||||
setLogs((prev) => [...prev.slice(-199), entry]);
|
setLogs((prev) => [...prev.slice(-199), entry]);
|
||||||
}
|
}
|
||||||
|
function handleAdminLogInit(entries = []) {
|
||||||
|
setAdminLogs(entries);
|
||||||
|
}
|
||||||
|
function handleAdminLogEntry(entry) {
|
||||||
|
setAdminLogs((prev) => [...prev.slice(-199), entry]);
|
||||||
|
}
|
||||||
|
function handleModerationStatus(payload = {}) {
|
||||||
|
const banned = Boolean(payload.banned);
|
||||||
|
setBanStatus(banned ? payload : null);
|
||||||
|
if (banned) {
|
||||||
|
setSession(null);
|
||||||
|
setLogs([]);
|
||||||
|
setAlerts([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleModerationInit(payload = {}) {
|
||||||
|
setModeration(payload);
|
||||||
|
}
|
||||||
|
function handleModerationUpdate(payload = {}) {
|
||||||
|
setModeration(payload);
|
||||||
|
}
|
||||||
socket.on('session:sync', handleSession);
|
socket.on('session:sync', handleSession);
|
||||||
socket.on('log:init', handleLogInit);
|
socket.on('log:init', handleLogInit);
|
||||||
socket.on('log:entry', handleLogEntry);
|
socket.on('log:entry', handleLogEntry);
|
||||||
|
socket.on('adminlog:init', handleAdminLogInit);
|
||||||
|
socket.on('adminlog:entry', handleAdminLogEntry);
|
||||||
|
socket.on('moderation:status', handleModerationStatus);
|
||||||
|
socket.on('moderation:init', handleModerationInit);
|
||||||
|
socket.on('moderation:update', handleModerationUpdate);
|
||||||
socket.on('alert:new', (payload = {}) => {
|
socket.on('alert:new', (payload = {}) => {
|
||||||
setAlerts((prev) => [
|
setAlerts((prev) => [
|
||||||
...prev.slice(-49),
|
...prev.slice(-49),
|
||||||
@@ -80,6 +115,11 @@ export function SessionProvider({ children }) {
|
|||||||
socket.off('session:sync', handleSession);
|
socket.off('session:sync', handleSession);
|
||||||
socket.off('log:init', handleLogInit);
|
socket.off('log:init', handleLogInit);
|
||||||
socket.off('log:entry', handleLogEntry);
|
socket.off('log:entry', handleLogEntry);
|
||||||
|
socket.off('adminlog:init', handleAdminLogInit);
|
||||||
|
socket.off('adminlog:entry', handleAdminLogEntry);
|
||||||
|
socket.off('moderation:status', handleModerationStatus);
|
||||||
|
socket.off('moderation:init', handleModerationInit);
|
||||||
|
socket.off('moderation:update', handleModerationUpdate);
|
||||||
socket.off('alert:new');
|
socket.off('alert:new');
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
@@ -100,6 +140,10 @@ export function SessionProvider({ children }) {
|
|||||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||||
|
banUser: (target, reason) => emitWithAck('moderation:ban', { target, reason }),
|
||||||
|
timeoutUser: (target, durationMs, reason) =>
|
||||||
|
emitWithAck('moderation:ban', { target, durationMs, reason }),
|
||||||
|
unbanUser: (target) => emitWithAck('moderation:unban', { target }),
|
||||||
pushAlert: (alert) =>
|
pushAlert: (alert) =>
|
||||||
setAlerts((prev) => [
|
setAlerts((prev) => [
|
||||||
...prev.slice(-49),
|
...prev.slice(-49),
|
||||||
@@ -114,10 +158,13 @@ export function SessionProvider({ children }) {
|
|||||||
connected,
|
connected,
|
||||||
session,
|
session,
|
||||||
logs,
|
logs,
|
||||||
|
adminLogs,
|
||||||
|
banStatus,
|
||||||
|
moderation,
|
||||||
alerts,
|
alerts,
|
||||||
...actions,
|
...actions,
|
||||||
}),
|
}),
|
||||||
[actions, alerts, connected, logs, session],
|
[actions, adminLogs, alerts, banStatus, connected, logs, moderation, session],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.j
|
|||||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import {
|
||||||
|
applyAuxOvercurrentScale,
|
||||||
|
applyDriveOvercurrentScale,
|
||||||
|
useOvercurrentLimiter,
|
||||||
|
} from './overcurrentLimiter.js';
|
||||||
|
|
||||||
const ControlSystemContext = createContext(null);
|
const ControlSystemContext = createContext(null);
|
||||||
|
|
||||||
@@ -23,7 +28,6 @@ function clampServoAngle(config, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ControlSystemProvider({ children }) {
|
export function ControlSystemProvider({ children }) {
|
||||||
const pipeline = useCommandPipeline();
|
|
||||||
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
||||||
const prevModeRef = useRef(null);
|
const prevModeRef = useRef(null);
|
||||||
const pendingLightsRef = useRef(false);
|
const pendingLightsRef = useRef(false);
|
||||||
@@ -33,6 +37,17 @@ export function ControlSystemProvider({ children }) {
|
|||||||
save: saveControlSettings,
|
save: saveControlSettings,
|
||||||
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
|
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
|
||||||
const { session, homeAssistantSetState } = useSession();
|
const { session, homeAssistantSetState } = useSession();
|
||||||
|
const roverId = session?.assignment?.roverId ?? null;
|
||||||
|
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
||||||
|
const driveTransform = useCallback(
|
||||||
|
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
|
||||||
|
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
|
||||||
|
);
|
||||||
|
const auxTransform = useCallback(
|
||||||
|
(values) => applyAuxOvercurrentScale(values, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
|
||||||
|
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
|
||||||
|
);
|
||||||
|
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
|
||||||
|
|
||||||
const turnOnAllLights = useCallback(() => {
|
const turnOnAllLights = useCallback(() => {
|
||||||
const entities = session?.homeAssistant?.entities || [];
|
const entities = session?.homeAssistant?.entities || [];
|
||||||
@@ -124,6 +139,39 @@ export function ControlSystemProvider({ children }) {
|
|||||||
dispatch({ type: 'control/record-intent' });
|
dispatch({ type: 'control/record-intent' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const driveSpeedsRef = useRef(state.drive.speeds);
|
||||||
|
const auxValuesRef = useRef(state.aux);
|
||||||
|
const limiterScaleToken = useMemo(() => JSON.stringify(overcurrentLimiter.scales), [overcurrentLimiter.scales]);
|
||||||
|
const limiterDriveSentAtRef = useRef(0);
|
||||||
|
const limiterAuxSentAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
driveSpeedsRef.current = state.drive.speeds;
|
||||||
|
}, [state.drive.speeds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
auxValuesRef.current = state.aux;
|
||||||
|
}, [state.aux]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pipeline.roverId || overcurrentLimiter.adminImmune || !overcurrentLimiter.isActive) return;
|
||||||
|
const outputRateMs = Math.max(0, Number(overcurrentLimiter?.config?.outputRateMs) || 0);
|
||||||
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
const drive = driveSpeedsRef.current || { left: 0, right: 0 };
|
||||||
|
const aux = auxValuesRef.current || { main: 0, side: 0, vacuum: 0 };
|
||||||
|
const driveActive = Boolean(drive.left || drive.right);
|
||||||
|
const auxActive = Boolean(aux.main || aux.side || aux.vacuum);
|
||||||
|
if (!driveActive && !auxActive) return;
|
||||||
|
if (driveActive && now - limiterDriveSentAtRef.current >= outputRateMs) {
|
||||||
|
limiterDriveSentAtRef.current = now;
|
||||||
|
pipeline.sendDriveDirect(drive);
|
||||||
|
}
|
||||||
|
if (auxActive && now - limiterAuxSentAtRef.current >= outputRateMs) {
|
||||||
|
limiterAuxSentAtRef.current = now;
|
||||||
|
pipeline.sendAuxMotors(aux);
|
||||||
|
}
|
||||||
|
}, [limiterScaleToken, overcurrentLimiter.adminImmune, overcurrentLimiter.config, overcurrentLimiter.isActive, pipeline]);
|
||||||
|
|
||||||
const setDriveVector = useCallback(
|
const setDriveVector = useCallback(
|
||||||
(vector, meta = {}) => {
|
(vector, meta = {}) => {
|
||||||
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
|
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
|
||||||
@@ -294,6 +342,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
state,
|
state,
|
||||||
dispatch,
|
dispatch,
|
||||||
pipeline,
|
pipeline,
|
||||||
|
overcurrentLimiter,
|
||||||
actions: {
|
actions: {
|
||||||
setMode,
|
setMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
@@ -316,6 +365,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
pipeline,
|
pipeline,
|
||||||
|
overcurrentLimiter,
|
||||||
setMode,
|
setMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
|
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
|
||||||
|
|
||||||
export function useCommandPipeline() {
|
export function useCommandPipeline(options = {}) {
|
||||||
|
const { driveTransform, auxTransform } = options;
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const roverId = session?.assignment?.roverId;
|
const roverId = session?.assignment?.roverId;
|
||||||
@@ -50,34 +51,45 @@ export function useCommandPipeline() {
|
|||||||
const sendDriveDirect = useCallback(
|
const sendDriveDirect = useCallback(
|
||||||
(speeds) => {
|
(speeds) => {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
const payload = {
|
const rawPayload = {
|
||||||
left: clampRange(speeds?.left ?? 0, [-500, 500]),
|
left: clampRange(speeds?.left ?? 0, [-500, 500]),
|
||||||
right: clampRange(speeds?.right ?? 0, [-500, 500]),
|
right: clampRange(speeds?.right ?? 0, [-500, 500]),
|
||||||
};
|
};
|
||||||
|
const transformed = driveTransform ? driveTransform(rawPayload) : rawPayload;
|
||||||
|
const payload = {
|
||||||
|
left: clampRange(transformed?.left ?? 0, [-500, 500]),
|
||||||
|
right: clampRange(transformed?.right ?? 0, [-500, 500]),
|
||||||
|
};
|
||||||
emitCommand({
|
emitCommand({
|
||||||
type: 'drive',
|
type: 'drive',
|
||||||
data: { driveDirect: payload },
|
data: { driveDirect: payload },
|
||||||
});
|
});
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
[emitCommand, roverId],
|
[driveTransform, emitCommand, roverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendAuxMotors = useCallback(
|
const sendAuxMotors = useCallback(
|
||||||
({ main = 0, side = 0, vacuum = 0 } = {}) => {
|
({ main = 0, side = 0, vacuum = 0 } = {}) => {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
const payload = {
|
const rawPayload = {
|
||||||
main: clampRange(main, AUX_LIMITS.main),
|
main: clampRange(main, AUX_LIMITS.main),
|
||||||
side: clampRange(side, AUX_LIMITS.side),
|
side: clampRange(side, AUX_LIMITS.side),
|
||||||
vacuum: clampRange(vacuum, AUX_LIMITS.vacuum),
|
vacuum: clampRange(vacuum, AUX_LIMITS.vacuum),
|
||||||
};
|
};
|
||||||
|
const transformed = auxTransform ? auxTransform(rawPayload) : rawPayload;
|
||||||
|
const payload = {
|
||||||
|
main: clampRange(transformed?.main ?? 0, AUX_LIMITS.main),
|
||||||
|
side: clampRange(transformed?.side ?? 0, AUX_LIMITS.side),
|
||||||
|
vacuum: clampRange(transformed?.vacuum ?? 0, AUX_LIMITS.vacuum),
|
||||||
|
};
|
||||||
emitCommand({
|
emitCommand({
|
||||||
type: 'motors',
|
type: 'motors',
|
||||||
data: { motorPwm: payload },
|
data: { motorPwm: payload },
|
||||||
});
|
});
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
[emitCommand, roverId],
|
[auxTransform, emitCommand, roverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendServoAngle = useCallback(
|
const sendServoAngle = useCallback(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { ControlSystemProvider, useControlSystem } from './ControlContext.jsx';
|
export { ControlSystemProvider, useControlSystem } from './ControlContext.jsx';
|
||||||
export { default as KeyboardInputManager } from './inputs/KeyboardInputManager.jsx';
|
export { default as KeyboardInputManager } from './inputs/KeyboardInputManager.jsx';
|
||||||
export { default as GamepadInputManager } from './inputs/GamepadInputManager.jsx';
|
export { default as GamepadInputManager } from './inputs/GamepadInputManager.jsx';
|
||||||
|
export { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
export const OVERCURRENT_GROUPS = [
|
||||||
|
{ key: 'drive', motors: ['leftWheel', 'rightWheel'] },
|
||||||
|
{ key: 'aux', motors: ['mainBrush', 'sideBrush'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_OVERCURRENT_LIMITS = {
|
||||||
|
downRatePerSec: 0.15,
|
||||||
|
upRatePerSec: 0.7,
|
||||||
|
releaseDelaySec: 1,
|
||||||
|
outputRateMs: 250,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createInitialCaps() {
|
||||||
|
return OVERCURRENT_GROUPS.reduce((acc, group) => {
|
||||||
|
acc[group.key] = { cap: 1, clearSec: 0 };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampUnit(value) {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOvercurrentLimiter(roverId, options = {}) {
|
||||||
|
const { session } = useSession();
|
||||||
|
const frame = useTelemetryFrame(roverId);
|
||||||
|
const sensors = frame?.sensors || {};
|
||||||
|
const overcurrentFlags = sensors?.wheelOvercurrents || {};
|
||||||
|
const config = useMemo(
|
||||||
|
() => ({ ...DEFAULT_OVERCURRENT_LIMITS, ...(options.config || {}) }),
|
||||||
|
[options.config],
|
||||||
|
);
|
||||||
|
const [caps, setCaps] = useState(() => createInitialCaps());
|
||||||
|
const lastTickRef = useRef(typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
|
const flagsRef = useRef(overcurrentFlags);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
flagsRef.current = overcurrentFlags || {};
|
||||||
|
}, [overcurrentFlags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCaps(createInitialCaps());
|
||||||
|
}, [roverId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
const deltaMs = Math.max(0, now - lastTickRef.current);
|
||||||
|
lastTickRef.current = now;
|
||||||
|
const deltaSec = deltaMs / 1000;
|
||||||
|
if (deltaSec <= 0) return;
|
||||||
|
setCaps((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = {};
|
||||||
|
const downRate = Number.isFinite(config.downRatePerSec) ? Math.max(0, config.downRatePerSec) : 0;
|
||||||
|
const upRate = Number.isFinite(config.upRatePerSec) ? Math.max(0, config.upRatePerSec) : 0;
|
||||||
|
const releaseDelay = Number.isFinite(config.releaseDelaySec) ? Math.max(0, config.releaseDelaySec) : 0;
|
||||||
|
OVERCURRENT_GROUPS.forEach((group) => {
|
||||||
|
const prevEntry = prev[group.key] || { cap: 1, clearSec: 0 };
|
||||||
|
const prevCap = Number.isFinite(prevEntry.cap) ? prevEntry.cap : 1;
|
||||||
|
const prevClear = Number.isFinite(prevEntry.clearSec) ? prevEntry.clearSec : 0;
|
||||||
|
const over = group.motors.some((motor) => Boolean(flagsRef.current?.[motor]));
|
||||||
|
const nextClear = over ? 0 : prevClear + deltaSec;
|
||||||
|
const allowRecover = !over && nextClear >= releaseDelay;
|
||||||
|
const nextCap = clampUnit(
|
||||||
|
over ? prevCap - downRate * deltaSec : allowRecover ? prevCap + upRate * deltaSec : prevCap,
|
||||||
|
);
|
||||||
|
if (Math.abs(nextCap - prevCap) > 0.0001 || Math.abs(nextClear - prevClear) > 0.0001) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
next[group.key] = { cap: nextCap, clearSec: nextClear };
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [config.downRatePerSec, config.releaseDelaySec, config.upRatePerSec]);
|
||||||
|
|
||||||
|
const scales = useMemo(() => {
|
||||||
|
const perGroup = OVERCURRENT_GROUPS.reduce((acc, group) => {
|
||||||
|
const entry = caps?.[group.key];
|
||||||
|
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
|
||||||
|
acc[group.key] = clampUnit(cap);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return {
|
||||||
|
perGroup,
|
||||||
|
drive: {
|
||||||
|
left: perGroup.drive ?? 1,
|
||||||
|
right: perGroup.drive ?? 1,
|
||||||
|
},
|
||||||
|
aux: {
|
||||||
|
main: perGroup.aux ?? 1,
|
||||||
|
side: perGroup.aux ?? 1,
|
||||||
|
vacuum: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [caps]);
|
||||||
|
|
||||||
|
const overcurrent = useMemo(() => {
|
||||||
|
const motors = {};
|
||||||
|
OVERCURRENT_GROUPS.forEach((group) => {
|
||||||
|
group.motors.forEach((motor) => {
|
||||||
|
motors[motor] = Boolean(overcurrentFlags?.[motor]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const groups = OVERCURRENT_GROUPS.reduce((acc, group) => {
|
||||||
|
acc[group.key] = group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]));
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return { motors, groups };
|
||||||
|
}, [overcurrentFlags]);
|
||||||
|
|
||||||
|
const adminImmune =
|
||||||
|
session?.role === 'admin' ||
|
||||||
|
session?.role === 'lockdown' ||
|
||||||
|
session?.role === 'lockdown-admin';
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
caps,
|
||||||
|
overcurrent,
|
||||||
|
scales,
|
||||||
|
isActive: (scales?.drive?.left ?? 1) < 1 || (scales?.drive?.right ?? 1) < 1 || (scales?.aux?.main ?? 1) < 1 || (scales?.aux?.side ?? 1) < 1,
|
||||||
|
config,
|
||||||
|
adminImmune,
|
||||||
|
}),
|
||||||
|
[caps, overcurrent, scales, config, adminImmune],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDriveOvercurrentScale(speeds = {}, scales, adminImmune = false) {
|
||||||
|
if (adminImmune || !scales?.drive) return speeds;
|
||||||
|
const leftScale = typeof scales.drive.left === 'number' ? scales.drive.left : 1;
|
||||||
|
const rightScale = typeof scales.drive.right === 'number' ? scales.drive.right : 1;
|
||||||
|
if (leftScale >= 0.999 && rightScale >= 0.999) return speeds;
|
||||||
|
return {
|
||||||
|
left: Math.round((speeds.left ?? 0) * leftScale),
|
||||||
|
right: Math.round((speeds.right ?? 0) * rightScale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAuxOvercurrentScale(values = {}, scales, adminImmune = false) {
|
||||||
|
if (adminImmune || !scales?.aux) return values;
|
||||||
|
const mainScale = typeof scales.aux.main === 'number' ? scales.aux.main : 1;
|
||||||
|
const sideScale = typeof scales.aux.side === 'number' ? scales.aux.side : 1;
|
||||||
|
const vacuumScale = typeof scales.aux.vacuum === 'number' ? scales.aux.vacuum : 1;
|
||||||
|
if (mainScale >= 0.999 && sideScale >= 0.999 && vacuumScale >= 0.999) return values;
|
||||||
|
return {
|
||||||
|
main: Math.round((values.main ?? 0) * mainScale),
|
||||||
|
side: Math.round((values.side ?? 0) * sideScale),
|
||||||
|
vacuum: Math.round((values.vacuum ?? 0) * vacuumScale),
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-2
@@ -44,11 +44,11 @@ body {
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.panel {
|
.panel {
|
||||||
@apply bg-black text-white p-0.5;
|
@apply bg-black text-white p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-section {
|
.panel-section {
|
||||||
@apply bg-neutral-900 text-white p-0.5;
|
@apply bg-neutral-900 text-white p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-muted {
|
.panel-muted {
|
||||||
|
|||||||
@@ -7,8 +7,27 @@ console.info('[socket] connecting to', resolvedUrl);
|
|||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
const transportPref = settings?.page?.connectionTransport || 'websocket';
|
const transportPref = settings?.page?.connectionTransport || 'websocket';
|
||||||
const transports = transportPref === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
const transports = transportPref === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
||||||
|
const CLIENT_ID_KEY = 'roverd_client_id';
|
||||||
|
|
||||||
|
function getClientId() {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(CLIENT_ID_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = (crypto?.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
window.localStorage.setItem(CLIENT_ID_KEY, created);
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = getClientId();
|
||||||
export const socket = io(resolvedUrl, {
|
export const socket = io(resolvedUrl, {
|
||||||
transports,
|
transports,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
|
auth: {
|
||||||
|
clientId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));
|
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
|||||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||||
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
||||||
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
||||||
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
|
||||||
import VideoTile from '../components/VideoTile.jsx';
|
import VideoTile from '../components/VideoTile.jsx';
|
||||||
import ChatPanel from '../components/ChatPanel.jsx';
|
import ChatPanel from '../components/ChatPanel.jsx';
|
||||||
import AlertFeed from '../components/AlertFeed.jsx';
|
import AlertFeed from '../components/AlertFeed.jsx';
|
||||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||||
|
import BannedOverlay from '../components/BannedOverlay.jsx';
|
||||||
|
|
||||||
const ROTATE_MS = 20000;
|
const ROTATE_MS = 20000;
|
||||||
|
const HARD_REFRESH_MS = 3 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function formatDriverLabel({ roverId, session }) {
|
function formatDriverLabel({ roverId, session }) {
|
||||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||||
@@ -29,47 +30,42 @@ function MiniSummaryContent() {
|
|||||||
const inLockdown = session?.mode === 'lockdown';
|
const inLockdown = session?.mode === 'lockdown';
|
||||||
const frames = useTelemetryFrames();
|
const frames = useTelemetryFrames();
|
||||||
const roster = session?.roster ?? [];
|
const roster = session?.roster ?? [];
|
||||||
const roomCameras = session?.roomCameras || [];
|
|
||||||
const feeds = useRoomCameraSnapshots(roomCameras.map((camera) => ({ id: camera.id })), {
|
|
||||||
enabled: !inLockdown,
|
|
||||||
version: session?.mode,
|
|
||||||
});
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
|
const activeDrivers = session?.activeDrivers || {};
|
||||||
|
const driverRoster = useMemo(
|
||||||
|
() => roster.filter((rover) => activeDrivers[rover.id]),
|
||||||
|
[roster, activeDrivers],
|
||||||
|
);
|
||||||
|
|
||||||
const snapshotFeeds = useRoverSnapshots(
|
const snapshotFeeds = useRoverSnapshots(
|
||||||
roster.map((rover) => rover.id),
|
driverRoster.map((rover) => rover.id),
|
||||||
{ enabled: !inLockdown, version: session?.mode },
|
{ enabled: !inLockdown, version: session?.mode },
|
||||||
);
|
);
|
||||||
const audioEntries = useMemo(
|
const audioEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
roster.flatMap((rover) => {
|
driverRoster.flatMap((rover) => {
|
||||||
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
|
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
|
||||||
const id = String(rover.id);
|
const id = String(rover.id);
|
||||||
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
|
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
|
||||||
}),
|
}),
|
||||||
[roster],
|
[driverRoster],
|
||||||
);
|
);
|
||||||
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
|
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
|
||||||
|
|
||||||
const roverPool = useMemo(() => {
|
const roverPool = useMemo(() => {
|
||||||
if (!roster.length) return [];
|
if (!driverRoster.length) return [];
|
||||||
const withSnapshot = roster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
|
const withSnapshot = driverRoster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
|
||||||
return withSnapshot.length ? withSnapshot : roster;
|
return withSnapshot.length ? withSnapshot : driverRoster;
|
||||||
}, [roster, snapshotFeeds]);
|
}, [driverRoster, snapshotFeeds]);
|
||||||
|
|
||||||
const rotationPool = useMemo(() => {
|
const rotationPool = useMemo(() => {
|
||||||
const items = [];
|
return roverPool.map((rover) => ({ type: 'rover', rover }));
|
||||||
roverPool.forEach((rover) => items.push({ type: 'rover', rover }));
|
}, [roverPool]);
|
||||||
roomCameras.forEach((camera) => items.push({ type: 'room', camera }));
|
|
||||||
return items;
|
|
||||||
}, [roverPool, roomCameras]);
|
|
||||||
|
|
||||||
const rotationKey = useMemo(
|
const rotationKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rotationPool
|
rotationPool
|
||||||
.map((entry) =>
|
.map((entry) => `r:${entry.rover.id}`)
|
||||||
entry.type === 'rover' ? `r:${entry.rover.id}` : `room:${entry.camera.id}`,
|
|
||||||
)
|
|
||||||
.join('|'),
|
.join('|'),
|
||||||
[rotationPool],
|
[rotationPool],
|
||||||
);
|
);
|
||||||
@@ -88,13 +84,11 @@ function MiniSummaryContent() {
|
|||||||
|
|
||||||
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
||||||
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
||||||
const activeCamera = activeEntry?.type === 'room' ? activeEntry.camera : null;
|
|
||||||
|
|
||||||
const activeSnapshot = activeRover ? snapshotFeeds[activeRover.id] || null : null;
|
const activeSnapshot = activeRover ? snapshotFeeds[activeRover.id] || null : null;
|
||||||
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
|
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
|
||||||
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
|
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
|
||||||
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
|
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
|
||||||
const activeFeed = activeCamera ? feeds[activeCamera.id] || null : null;
|
|
||||||
|
|
||||||
if (inLockdown) {
|
if (inLockdown) {
|
||||||
return (
|
return (
|
||||||
@@ -108,7 +102,7 @@ function MiniSummaryContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-screen overflow-hidden bg-black p-0.5 text-slate-100 flex flex-col gap-0.5">
|
<div className="relative h-screen w-screen overflow-hidden bg-black p-0 text-slate-100 flex flex-col gap-0.5">
|
||||||
<ChatOverlay />
|
<ChatOverlay />
|
||||||
<section className="panel relative flex min-h-0 flex-1 overflow-hidden">
|
<section className="panel relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
{!spectatorReady ? (
|
{!spectatorReady ? (
|
||||||
@@ -128,18 +122,15 @@ function MiniSummaryContent() {
|
|||||||
layoutFormat="mobile"
|
layoutFormat="mobile"
|
||||||
hudVariant="spectator"
|
hudVariant="spectator"
|
||||||
driverLabel={driverLabel}
|
driverLabel={driverLabel}
|
||||||
|
hudLabelScale={5}
|
||||||
hudForceMap
|
hudForceMap
|
||||||
hudMapPosition="bottom-left"
|
hudMapPosition="bottom-left"
|
||||||
fitParent
|
fitParent
|
||||||
/>
|
/>
|
||||||
</FitViewportFrame>
|
</FitViewportFrame>
|
||||||
) : activeCamera ? (
|
|
||||||
<FitViewportFrame>
|
|
||||||
<RoomCameraFrame camera={activeCamera} feed={activeFeed} />
|
|
||||||
</FitViewportFrame>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
||||||
No sources available.
|
{driverRoster.length ? 'No sources available.' : 'No active drivers.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -148,40 +139,27 @@ function MiniSummaryContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MiniSummaryApp() {
|
export default function MiniSummaryApp() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return undefined;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('refresh', Date.now().toString());
|
||||||
|
window.location.replace(url.toString());
|
||||||
|
}, HARD_REFRESH_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<>
|
<>
|
||||||
<MiniSummaryContent />
|
<MiniSummaryContent />
|
||||||
<AlertFeed />
|
<AlertFeed scale={3} />
|
||||||
|
<BannedOverlay />
|
||||||
</>
|
</>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomCameraFrame({ camera, feed }) {
|
|
||||||
const hasImage = feed?.objectUrl;
|
|
||||||
const connecting = feed && feed.status === 'connecting';
|
|
||||||
return (
|
|
||||||
<div className="relative h-full w-full bg-zinc-950">
|
|
||||||
{hasImage ? (
|
|
||||||
<img
|
|
||||||
src={feed.objectUrl}
|
|
||||||
alt={camera.name || camera.id}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
|
||||||
{connecting ? `Connecting to ${camera.name || camera.id}…` : 'No frame yet'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute left-1 top-1 rounded bg-black/60 px-1 py-0.25 text-xs text-slate-200">
|
|
||||||
{camera.name || camera.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatOverlay() {
|
function ChatOverlay() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -204,11 +182,11 @@ function FitViewportFrame({ children }) {
|
|||||||
<div
|
<div
|
||||||
className="relative flex items-center justify-center overflow-hidden bg-black"
|
className="relative flex items-center justify-center overflow-hidden bg-black"
|
||||||
style={{
|
style={{
|
||||||
width: 'min(100%, calc(100vh * 16 / 9))',
|
width: 'min(100%, calc(100vh * 4 / 3))',
|
||||||
height: 'min(100%, calc(100vw * 9 / 16))',
|
height: 'min(100%, calc(100vw * 3 / 4))',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '4 / 3',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
|
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
|||||||
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
||||||
import VideoTile from '../components/VideoTile.jsx';
|
import VideoTile from '../components/VideoTile.jsx';
|
||||||
import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
|
import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
|
||||||
import UserListPanel from '../components/UserListPanel.jsx';
|
|
||||||
import ChatPanel from '../components/ChatPanel.jsx';
|
import ChatPanel from '../components/ChatPanel.jsx';
|
||||||
import LogPanel from '../components/LogPanel.jsx';
|
import LogPanel from '../components/LogPanel.jsx';
|
||||||
import RoverRoster from '../components/RoverRoster.jsx';
|
|
||||||
import AlertFeed from '../components/AlertFeed.jsx';
|
import AlertFeed from '../components/AlertFeed.jsx';
|
||||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||||
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
||||||
|
import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx';
|
||||||
|
import RawUserPilePanel from '../components/RawUserPilePanel.jsx';
|
||||||
|
import BannedOverlay from '../components/BannedOverlay.jsx';
|
||||||
|
|
||||||
function formatDriverLabel({ roverId, session }) {
|
function formatDriverLabel({ roverId, session }) {
|
||||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||||
@@ -28,7 +29,7 @@ function formatDriverLabel({ roverId, session }) {
|
|||||||
function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
|
function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
|
||||||
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
||||||
return (
|
return (
|
||||||
<article className="min-h-[16rem] rounded bg-zinc-900 p-0.5 sm:min-h-[18rem]">
|
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
|
||||||
<div className="min-h-0 overflow-hidden rounded bg-black/20">
|
<div className="min-h-0 overflow-hidden rounded bg-black/20">
|
||||||
<VideoTile
|
<VideoTile
|
||||||
sessionInfo={null}
|
sessionInfo={null}
|
||||||
@@ -124,7 +125,7 @@ function SpectatorContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden">
|
<div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden">
|
||||||
<main className="grid min-h-screen grid-cols-1 gap-0.5 p-0.5 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<main className="grid min-h-screen grid-cols-1 gap-0.5 p-0 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto">
|
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto">
|
||||||
<RoverRow
|
<RoverRow
|
||||||
roster={roster}
|
roster={roster}
|
||||||
@@ -138,10 +139,10 @@ function SpectatorContent() {
|
|||||||
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
|
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
|
||||||
<CommunityGoalBanner layout="desktop" />
|
<CommunityGoalBanner layout="desktop" />
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
|
<RoverQueuesPanel title="Rovers" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||||
<UserListPanel hideNicknameForm hideHeader fillHeight className="h-full" />
|
<RawUserPilePanel hideNicknameForm hideHeader fillHeight className="h-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
|
<div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
|
||||||
<ChatPanel hideInput hideSpectatorNotice fillHeight />
|
<ChatPanel hideInput hideSpectatorNotice fillHeight />
|
||||||
@@ -159,7 +160,10 @@ function SpectatorContent() {
|
|||||||
export default function SpectatorApp() {
|
export default function SpectatorApp() {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<SpectatorContent />
|
<>
|
||||||
|
<SpectatorContent />
|
||||||
|
<BannedOverlay />
|
||||||
|
</>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user