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
+3
View File
@@ -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 />
+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 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>
);
}
+2
View File
@@ -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),
+2
View File
@@ -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>