mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
community goal system
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"text": "Flip the green slime box",
|
||||
"updatedAt": 1768597355934,
|
||||
"updatedBy": "355503317503311872"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/nicknameService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/communityGoalService');
|
||||
require('./src/services/videoSessions');
|
||||
require('./src/services/videoAuthService');
|
||||
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-poMXYqdO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hvTkRjRF.css">
|
||||
<script type="module" crossorigin src="/assets/index-BrK8dCMT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
|
||||
</head>
|
||||
<body>
|
||||
<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,
|
||||
};
|
||||
@@ -20,6 +20,7 @@ const { getReplaySources, getDefaultDiscordSources, validateSources } = require(
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { tryTriggerReplay } = require('./replayService');
|
||||
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
|
||||
const {
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
@@ -54,6 +55,9 @@ const client = new Client({
|
||||
|
||||
const channelCache = new Map();
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
const PRESENCE_ROTATE_MS = 20000;
|
||||
let presenceInterval = null;
|
||||
let presenceShowGoal = false;
|
||||
function sanitizeMentions(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
@@ -158,13 +162,30 @@ function countReady() {
|
||||
return { ready, total };
|
||||
}
|
||||
|
||||
async function updatePresence() {
|
||||
if (!client?.user) return;
|
||||
function truncatePresenceText(text, maxLength) {
|
||||
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 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 {
|
||||
await client.user.setPresence({
|
||||
activities: [{ name: `${mode} · ${ready}/${total} Rovers Ready`, type: ActivityType.Watching }],
|
||||
activities: [{ name: buildPresenceName(), type: ActivityType.Watching }],
|
||||
status: 'online',
|
||||
});
|
||||
} 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) {
|
||||
if (!id) return null;
|
||||
if (channelCache.has(id)) return channelCache.get(id);
|
||||
@@ -215,6 +255,7 @@ function formatHelp() {
|
||||
'`rs lock <id>` — lock a rover',
|
||||
'`rs unlock <id>` — unlock a rover',
|
||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||
'`rs goal [text|clear]` — show or set community goal',
|
||||
'`ts` — show time status',
|
||||
].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) {
|
||||
if (isAdminUser(message.author.id)) return true;
|
||||
if (!message.guild || !message.member) return false;
|
||||
@@ -600,7 +692,8 @@ async function handleCommand(message) {
|
||||
action !== 'status' &&
|
||||
action !== 'help' &&
|
||||
action !== 'replay' &&
|
||||
action !== 'bridge'
|
||||
action !== 'bridge' &&
|
||||
action !== 'goal'
|
||||
) {
|
||||
return; // ignore non-admins for privileged commands
|
||||
}
|
||||
@@ -630,6 +723,9 @@ async function handleCommand(message) {
|
||||
case 'mode':
|
||||
await handleModeCommand(message, tokens[0]);
|
||||
break;
|
||||
case 'goal':
|
||||
await handleGoalCommand(message, tokens);
|
||||
break;
|
||||
default:
|
||||
await message.reply(formatHelp());
|
||||
break;
|
||||
@@ -865,7 +961,7 @@ function handleBusEvent(event) {
|
||||
case 'mode.changed':
|
||||
if (!skippedFirstModeAnnouncement) {
|
||||
skippedFirstModeAnnouncement = true;
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
}
|
||||
announce({
|
||||
@@ -875,8 +971,19 @@ function handleBusEvent(event) {
|
||||
title: 'Mode Changed',
|
||||
description: `Server mode set to **${payload?.mode}**`,
|
||||
});
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
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':
|
||||
announce({
|
||||
channelId: channels.announcements,
|
||||
@@ -884,7 +991,7 @@ function handleBusEvent(event) {
|
||||
title: 'Rover Locked',
|
||||
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
|
||||
});
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
case 'rover.unlocked':
|
||||
announce({
|
||||
@@ -894,7 +1001,7 @@ function handleBusEvent(event) {
|
||||
title: 'Rover Unlocked',
|
||||
description: `${payload?.roverId} unlocked.`,
|
||||
});
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
case 'rover.online':
|
||||
announce({
|
||||
@@ -904,7 +1011,7 @@ function handleBusEvent(event) {
|
||||
title: 'Rover Online',
|
||||
description: `${payload?.roverId} is online.`,
|
||||
});
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
case 'rover.offline':
|
||||
announce({
|
||||
@@ -914,7 +1021,7 @@ function handleBusEvent(event) {
|
||||
title: 'Rover Offline',
|
||||
description: `${payload?.roverId} went offline.`,
|
||||
});
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
case 'rover.dockGuard':
|
||||
announce({
|
||||
@@ -997,7 +1104,7 @@ function handleBusEvent(event) {
|
||||
description: null,
|
||||
embeds: [buildBatteryStatusEmbed(0xf0b651)],
|
||||
});
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
case 'battery.unlocked':
|
||||
announce({
|
||||
@@ -1008,7 +1115,7 @@ function handleBusEvent(event) {
|
||||
description: null,
|
||||
embeds: [buildBatteryStatusEmbed(0x4caf50)],
|
||||
});
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
case 'replay.requested':
|
||||
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => {
|
||||
@@ -1063,7 +1170,7 @@ client.on('messageCreate', async (message) => {
|
||||
|
||||
client.once('ready', () => {
|
||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||
updatePresence();
|
||||
schedulePresenceRotation();
|
||||
});
|
||||
|
||||
subscribe('*', handleBusEvent);
|
||||
|
||||
@@ -13,6 +13,8 @@ const { getReplayState, replayEvents } = require('./replayService');
|
||||
const { getReplaySources } = require('./replaySourceService');
|
||||
const { getHealthSnapshot } = require('./healthService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { getCommunityGoal } = require('./communityGoalService');
|
||||
const { subscribe } = require('./eventBus');
|
||||
|
||||
const discordInvite = loadConfig().discord?.invite || null;
|
||||
const kofiLink = loadConfig().kofi?.link || null;
|
||||
@@ -53,6 +55,7 @@ function buildSession(socket) {
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(),
|
||||
health: getHealthSnapshot(),
|
||||
communityGoal: getCommunityGoal(),
|
||||
users,
|
||||
discord: {
|
||||
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
|
||||
setInterval(() => {
|
||||
logger.info('Periodic session sync for all clients');
|
||||
|
||||
@@ -26,6 +26,7 @@ import HelpPanel from './components/HelpPanel.jsx';
|
||||
import SettingsPanel from './components/SettingsPanel.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
|
||||
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
@@ -73,6 +74,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||
<LogPanel />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||
<CommunityGoalBanner layout={layout} />
|
||||
<RightPaneTabs layout={layout} onOpenHelpOverlay={onOpenHelpOverlay} />
|
||||
{/* <SessionSnapshot /> */}
|
||||
</div>
|
||||
@@ -234,6 +236,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
|
||||
{!isDesktop ? <CommunityGoalBanner layout={layout} /> : null}
|
||||
{renderedLayout}
|
||||
</main>
|
||||
<AlertFeed />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import RoverRoster from './RoverRoster.jsx';
|
||||
|
||||
@@ -10,10 +10,13 @@ const MODES = [
|
||||
];
|
||||
|
||||
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 [lockStates, setLockStates] = useState({});
|
||||
const health = session?.health || null;
|
||||
const currentGoal = session?.communityGoal?.text || '';
|
||||
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
|
||||
const [goalDraft, setGoalDraft] = useState(currentGoal);
|
||||
|
||||
const isAdmin =
|
||||
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 map = {};
|
||||
roster.forEach((rover) => {
|
||||
@@ -70,6 +93,29 @@ export default function AdminPanel() {
|
||||
))}
|
||||
</select>
|
||||
</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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const SessionContext = createContext({
|
||||
homeAssistantSetState: async () => {},
|
||||
setNickname: async () => {},
|
||||
triggerReplay: async () => {},
|
||||
setCommunityGoal: async () => {},
|
||||
});
|
||||
|
||||
function useAckEmitter(socket) {
|
||||
@@ -98,6 +99,7 @@ export function SessionProvider({ children }) {
|
||||
emitWithAck('homeAssistant:setState', { entityId, state }),
|
||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||
pushAlert: (alert) =>
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
|
||||
@@ -12,6 +12,7 @@ import LogPanel from '../components/LogPanel.jsx';
|
||||
import RoverRoster from '../components/RoverRoster.jsx';
|
||||
import AlertFeed from '../components/AlertFeed.jsx';
|
||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
||||
|
||||
function formatDriverLabel({ roverId, session }) {
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||
@@ -135,6 +136,7 @@ function SpectatorContent() {
|
||||
<SecondaryRow />
|
||||
</section>
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
|
||||
<CommunityGoalBanner layout="desktop" />
|
||||
<div className="panel">
|
||||
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user