community goal system

This commit is contained in:
legop3
2026-01-16 16:02:56 -05:00
parent 64a9dd3583
commit 90fd0262c7
15 changed files with 517 additions and 137 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"text": "Flip the green slime box",
"updatedAt": 1768597355934,
"updatedBy": "355503317503311872"
}
+1
View File
@@ -17,6 +17,7 @@ require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/nicknameService'); require('./src/services/nicknameService');
require('./src/services/chatService'); require('./src/services/chatService');
require('./src/services/communityGoalService');
require('./src/services/videoSessions'); require('./src/services/videoSessions');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
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
+2 -2
View File
@@ -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-poMXYqdO.js"></script> <script type="module" crossorigin src="/assets/index-BrK8dCMT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hvTkRjRF.css"> <link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -0,0 +1,95 @@
const fs = require('fs');
const path = require('path');
const io = require('../globals/io');
const logger = require('../globals/logger').child('communityGoalService');
const { isAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'community-goal.json');
const MAX_GOAL_LENGTH = 240;
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 community goal', err.message);
}
cache = null;
}
return cache;
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function normalizeText(input) {
if (typeof input !== 'string') return '';
return input.replace(/\s+/g, ' ').trim();
}
function getCommunityGoal() {
return loadStore();
}
function setCommunityGoal(text, meta = {}) {
const clean = normalizeText(text);
if (!clean) {
throw new Error('Goal text required');
}
if (clean.length > MAX_GOAL_LENGTH) {
throw new Error(`Goal too long (max ${MAX_GOAL_LENGTH} chars)`);
}
const payload = {
text: clean,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
return payload;
}
function clearCommunityGoal(meta = {}) {
const payload = {
text: null,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
return payload;
}
io.on('connection', (socket) => {
socket.on('communityGoal:set', ({ text } = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const result =
text == null || String(text).trim() === ''
? clearCommunityGoal({ by: socket?.data?.user?.username || socket?.id })
: setCommunityGoal(text, { by: socket?.data?.user?.username || socket?.id });
cb({ success: true, goal: result });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getCommunityGoal,
setCommunityGoal,
clearCommunityGoal,
MAX_GOAL_LENGTH,
};
+120 -13
View File
@@ -20,6 +20,7 @@ const { getReplaySources, getDefaultDiscordSources, validateSources } = require(
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 { const {
getGuildConfig, getGuildConfig,
listGuildConfigs, listGuildConfigs,
@@ -54,6 +55,9 @@ const client = new Client({
const channelCache = new Map(); const channelCache = new Map();
let skippedFirstModeAnnouncement = false; let skippedFirstModeAnnouncement = false;
const PRESENCE_ROTATE_MS = 20000;
let presenceInterval = null;
let presenceShowGoal = false;
function sanitizeMentions(text) { function sanitizeMentions(text) {
if (!text) return ''; if (!text) return '';
return String(text) return String(text)
@@ -158,13 +162,30 @@ function countReady() {
return { ready, total }; return { ready, total };
} }
async function updatePresence() { function truncatePresenceText(text, maxLength) {
if (!client?.user) return; if (!text) return '';
if (text.length <= maxLength) return text;
if (maxLength <= 3) return text.slice(0, maxLength);
return `${text.slice(0, maxLength - 3)}...`;
}
function buildPresenceName() {
const { ready, total } = countReady(); const { ready, total } = countReady();
const mode = getMode(); const mode = getMode();
const goal = getCommunityGoal();
const goalText = goal?.text ? String(goal.text).trim() : '';
if (presenceShowGoal && goalText) {
const trimmed = truncatePresenceText(goalText, 110);
return `Goal: ${trimmed}`;
}
return `${mode} · ${ready}/${total} Rovers Ready`;
}
async function updatePresence() {
if (!client?.user) return;
try { try {
await client.user.setPresence({ await client.user.setPresence({
activities: [{ name: `${mode} · ${ready}/${total} Rovers Ready`, type: ActivityType.Watching }], activities: [{ name: buildPresenceName(), type: ActivityType.Watching }],
status: 'online', status: 'online',
}); });
} catch (err) { } catch (err) {
@@ -172,6 +193,25 @@ async function updatePresence() {
} }
} }
function schedulePresenceRotation() {
if (presenceInterval) {
clearInterval(presenceInterval);
presenceInterval = null;
}
const goal = getCommunityGoal();
if (!goal?.text) {
presenceShowGoal = false;
updatePresence();
return;
}
presenceShowGoal = false;
updatePresence();
presenceInterval = setInterval(() => {
presenceShowGoal = !presenceShowGoal;
updatePresence();
}, PRESENCE_ROTATE_MS);
}
async function fetchChannel(id) { async function fetchChannel(id) {
if (!id) return null; if (!id) return null;
if (channelCache.has(id)) return channelCache.get(id); if (channelCache.has(id)) return channelCache.get(id);
@@ -215,6 +255,7 @@ function formatHelp() {
'`rs lock <id>` — lock a rover', '`rs lock <id>` — lock a rover',
'`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',
'`ts` — show time status', '`ts` — show time status',
].join('\n'); ].join('\n');
} }
@@ -417,6 +458,57 @@ async function handleModeCommand(message, mode) {
} }
} }
async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
if (!query) {
const goal = getCommunityGoal();
const text = goal?.text ? goal.text : null;
await message.reply({
content: text ? `Community goal: ${sanitizeMentions(text)}` : 'No community goal set.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (!isAdminUser(message.author.id)) {
await message.reply({
content: 'Only admins can update the community goal.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (lower === 'clear') {
try {
clearCommunityGoal({ by: message.author?.id || null });
await message.reply({
content: 'Community goal cleared.',
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to clear goal: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
return;
}
try {
setCommunityGoal(query, { by: message.author?.id || null });
await message.reply({
content: sanitizeMentions(`Community goal set: ${query}`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to set goal: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
}
function canManageBridge(message) { function canManageBridge(message) {
if (isAdminUser(message.author.id)) return true; if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false; if (!message.guild || !message.member) return false;
@@ -600,7 +692,8 @@ async function handleCommand(message) {
action !== 'status' && action !== 'status' &&
action !== 'help' && action !== 'help' &&
action !== 'replay' && action !== 'replay' &&
action !== 'bridge' action !== 'bridge' &&
action !== 'goal'
) { ) {
return; // ignore non-admins for privileged commands return; // ignore non-admins for privileged commands
} }
@@ -630,6 +723,9 @@ async function handleCommand(message) {
case 'mode': case 'mode':
await handleModeCommand(message, tokens[0]); await handleModeCommand(message, tokens[0]);
break; break;
case 'goal':
await handleGoalCommand(message, tokens);
break;
default: default:
await message.reply(formatHelp()); await message.reply(formatHelp());
break; break;
@@ -865,7 +961,7 @@ function handleBusEvent(event) {
case 'mode.changed': case 'mode.changed':
if (!skippedFirstModeAnnouncement) { if (!skippedFirstModeAnnouncement) {
skippedFirstModeAnnouncement = true; skippedFirstModeAnnouncement = true;
updatePresence(); schedulePresenceRotation();
break; break;
} }
announce({ announce({
@@ -875,8 +971,19 @@ function handleBusEvent(event) {
title: 'Mode Changed', title: 'Mode Changed',
description: `Server mode set to **${payload?.mode}**`, description: `Server mode set to **${payload?.mode}**`,
}); });
updatePresence(); schedulePresenceRotation();
break; break;
case 'communityGoal.updated': {
const goalText = payload?.text ? sanitizeMentions(String(payload.text)) : null;
announce({
channelId: channels.announcements,
color: 0x8bc34a,
title: 'Community Goal',
description: goalText ? goalText : 'Community goal cleared.',
});
schedulePresenceRotation();
break;
}
case 'rover.locked': case 'rover.locked':
announce({ announce({
channelId: channels.announcements, channelId: channels.announcements,
@@ -884,7 +991,7 @@ function handleBusEvent(event) {
title: 'Rover Locked', title: 'Rover Locked',
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`, description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
}); });
updatePresence(); schedulePresenceRotation();
break; break;
case 'rover.unlocked': case 'rover.unlocked':
announce({ announce({
@@ -894,7 +1001,7 @@ function handleBusEvent(event) {
title: 'Rover Unlocked', title: 'Rover Unlocked',
description: `${payload?.roverId} unlocked.`, description: `${payload?.roverId} unlocked.`,
}); });
updatePresence(); schedulePresenceRotation();
break; break;
case 'rover.online': case 'rover.online':
announce({ announce({
@@ -904,7 +1011,7 @@ function handleBusEvent(event) {
title: 'Rover Online', title: 'Rover Online',
description: `${payload?.roverId} is online.`, description: `${payload?.roverId} is online.`,
}); });
updatePresence(); schedulePresenceRotation();
break; break;
case 'rover.offline': case 'rover.offline':
announce({ announce({
@@ -914,7 +1021,7 @@ function handleBusEvent(event) {
title: 'Rover Offline', title: 'Rover Offline',
description: `${payload?.roverId} went offline.`, description: `${payload?.roverId} went offline.`,
}); });
updatePresence(); schedulePresenceRotation();
break; break;
case 'rover.dockGuard': case 'rover.dockGuard':
announce({ announce({
@@ -997,7 +1104,7 @@ function handleBusEvent(event) {
description: null, description: null,
embeds: [buildBatteryStatusEmbed(0xf0b651)], embeds: [buildBatteryStatusEmbed(0xf0b651)],
}); });
updatePresence(); schedulePresenceRotation();
break; break;
case 'battery.unlocked': case 'battery.unlocked':
announce({ announce({
@@ -1008,7 +1115,7 @@ function handleBusEvent(event) {
description: null, description: null,
embeds: [buildBatteryStatusEmbed(0x4caf50)], embeds: [buildBatteryStatusEmbed(0x4caf50)],
}); });
updatePresence(); schedulePresenceRotation();
break; break;
case 'replay.requested': case 'replay.requested':
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => { sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => {
@@ -1063,7 +1170,7 @@ client.on('messageCreate', async (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 });
updatePresence(); schedulePresenceRotation();
}); });
subscribe('*', handleBusEvent); subscribe('*', handleBusEvent);
+8
View File
@@ -13,6 +13,8 @@ const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService'); const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService'); const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { getCommunityGoal } = require('./communityGoalService');
const { subscribe } = require('./eventBus');
const discordInvite = loadConfig().discord?.invite || null; const discordInvite = loadConfig().discord?.invite || null;
const kofiLink = loadConfig().kofi?.link || null; const kofiLink = loadConfig().kofi?.link || null;
@@ -53,6 +55,7 @@ function buildSession(socket) {
replay: getReplayState(), replay: getReplayState(),
replaySources: getReplaySources(), replaySources: getReplaySources(),
health: getHealthSnapshot(), health: getHealthSnapshot(),
communityGoal: getCommunityGoal(),
users, users,
discord: { discord: {
invite: discordInvite, invite: discordInvite,
@@ -182,6 +185,11 @@ nicknameEvents.on('change', ({ socketId }) => {
} }
}); });
subscribe('communityGoal.updated', () => {
logger.info('Community goal updated; syncing all clients');
syncAll();
});
// sync all sockets 20 seconds // sync all sockets 20 seconds
setInterval(() => { setInterval(() => {
logger.info('Periodic session sync for all clients'); logger.info('Periodic session sync for all clients');
+3
View File
@@ -26,6 +26,7 @@ import HelpPanel from './components/HelpPanel.jsx';
import SettingsPanel from './components/SettingsPanel.jsx'; 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';
function useLayoutMode() { function useLayoutMode() {
const [mode, setMode] = useState(() => { const [mode, setMode] = useState(() => {
@@ -73,6 +74,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
<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">
<CommunityGoalBanner layout={layout} />
<RightPaneTabs layout={layout} onOpenHelpOverlay={onOpenHelpOverlay} /> <RightPaneTabs layout={layout} onOpenHelpOverlay={onOpenHelpOverlay} />
{/* <SessionSnapshot /> */} {/* <SessionSnapshot /> */}
</div> </div>
@@ -234,6 +236,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
<KeyboardInputManager /> <KeyboardInputManager />
<GamepadInputManager /> <GamepadInputManager />
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}> <main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
{!isDesktop ? <CommunityGoalBanner layout={layout} /> : null}
{renderedLayout} {renderedLayout}
</main> </main>
<AlertFeed /> <AlertFeed />
+48 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import RoverRoster from './RoverRoster.jsx'; import RoverRoster from './RoverRoster.jsx';
@@ -10,10 +10,13 @@ const MODES = [
]; ];
export default function AdminPanel() { export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession(); const { session, lockRover, setMode, requestControl, setCommunityGoal } = 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;
const currentGoal = session?.communityGoal?.text || '';
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
const [goalDraft, setGoalDraft] = useState(currentGoal);
const isAdmin = const isAdmin =
session?.role === 'admin' || session?.role === 'admin' ||
@@ -48,6 +51,26 @@ export default function AdminPanel() {
} }
}; };
const handleGoalSave = async () => {
try {
await setCommunityGoal(goalDraft);
} catch (err) {
alert(err.message);
}
};
const handleGoalClear = async () => {
try {
await setCommunityGoal(null);
} catch (err) {
alert(err.message);
}
};
useEffect(() => {
setGoalDraft(currentGoal);
}, [currentGoal]);
const lockMap = useMemo(() => { const lockMap = useMemo(() => {
const map = {}; const map = {};
roster.forEach((rover) => { roster.forEach((rover) => {
@@ -70,6 +93,29 @@ export default function AdminPanel() {
))} ))}
</select> </select>
</div> </div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Community goal</span>
{goalUpdatedAt ? (
<span>Updated {new Date(goalUpdatedAt).toLocaleString()}</span>
) : null}
</div>
<input
type="text"
value={goalDraft}
onChange={(event) => setGoalDraft(event.target.value)}
placeholder="Set a community goal"
className="field-input text-sm"
/>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleGoalSave} className="button-dark">
Set goal
</button>
<button type="button" onClick={handleGoalClear} className="button-danger">
Clear
</button>
</div>
</div>
<RoverRoster <RoverRoster
roster={roster} roster={roster}
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
const MOBILE_DISMISS_MS = 10000;
const MAX_FONT_PX = 28;
const MIN_FONT_PX = 14;
export default function CommunityGoalBanner({ layout = 'desktop', className = '' }) {
const { session } = useSession();
const goalText = session?.communityGoal?.text ? String(session.communityGoal.text).trim() : '';
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
const [visible, setVisible] = useState(false);
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
const [remainingMs, setRemainingMs] = useState(0);
const containerRef = useRef(null);
const textRef = useRef(null);
const textContainerRef = useRef(null);
useEffect(() => {
if (!goalText) {
setVisible(false);
return undefined;
}
setVisible(true);
if (!isMobile) return undefined;
const startedAt = Date.now();
setRemainingMs(MOBILE_DISMISS_MS);
const timer = setTimeout(() => setVisible(false), MOBILE_DISMISS_MS);
const tick = setInterval(() => {
const elapsed = Date.now() - startedAt;
setRemainingMs(Math.max(0, MOBILE_DISMISS_MS - elapsed));
}, 250);
return () => {
clearTimeout(timer);
clearInterval(tick);
};
}, [goalText, isMobile]);
useEffect(() => {
if (!goalText) return undefined;
setFontSize(MAX_FONT_PX);
let rafId = 0;
const adjustFont = () => {
const textContainer = textContainerRef.current;
const text = textRef.current;
if (!textContainer || !text) return;
const available = textContainer.clientWidth;
if (!available) return;
const needed = text.scrollWidth;
if (!needed) return;
const scale = Math.min(1, available / needed);
const nextSize = Math.max(MIN_FONT_PX, Math.floor(MAX_FONT_PX * scale));
setFontSize(nextSize);
};
rafId = window.requestAnimationFrame(adjustFont);
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(adjustFont);
});
if (containerRef.current) observer.observe(containerRef.current);
return () => {
window.cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [goalText]);
const containerClass = useMemo(
() =>
[
'panel-section flex w-full items-center justify-center',
isMobile ? 'rounded-none' : 'rounded',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
]
.filter(Boolean)
.join(' '),
[className, isMobile],
);
if (!goalText || !visible) return null;
const remainingSeconds = isMobile ? Math.ceil(remainingMs / 1000) : null;
return (
<div
ref={containerRef}
className={containerClass}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
onClick={() => setVisible(false)}
role="button"
tabIndex={0}
>
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap">
<span className="flex flex-col justify-center border-r border-slate-700/60 px-0.5 text-[0.55em] font-semibold leading-tight text-slate-400">
<span>Community</span>
<span>Goal</span>
</span>
<span ref={textContainerRef} className="flex-1 overflow-hidden text-slate-100">
<span ref={textRef} className="block">
{goalText}
</span>
</span>
{isMobile ? (
<span className="flex items-center border-l border-slate-700/60 px-0.5 text-[0.55em] font-semibold uppercase tracking-wide text-slate-400">
{remainingSeconds}s
</span>
) : null}
</span>
</div>
);
}
+2
View File
@@ -16,6 +16,7 @@ const SessionContext = createContext({
homeAssistantSetState: async () => {}, homeAssistantSetState: async () => {},
setNickname: async () => {}, setNickname: async () => {},
triggerReplay: async () => {}, triggerReplay: async () => {},
setCommunityGoal: async () => {},
}); });
function useAckEmitter(socket) { function useAckEmitter(socket) {
@@ -98,6 +99,7 @@ export function SessionProvider({ children }) {
emitWithAck('homeAssistant:setState', { entityId, state }), emitWithAck('homeAssistant:setState', { entityId, state }),
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 }),
pushAlert: (alert) => pushAlert: (alert) =>
setAlerts((prev) => [ setAlerts((prev) => [
...prev.slice(-49), ...prev.slice(-49),
+2
View File
@@ -12,6 +12,7 @@ import LogPanel from '../components/LogPanel.jsx';
import RoverRoster from '../components/RoverRoster.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';
function formatDriverLabel({ roverId, session }) { function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null; const activeDriverId = session?.activeDrivers?.[roverId] || null;
@@ -135,6 +136,7 @@ function SpectatorContent() {
<SecondaryRow /> <SecondaryRow />
</section> </section>
<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" />
<div className="panel"> <div className="panel">
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." /> <RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
</div> </div>