mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
+8
-4
@@ -1,7 +1,11 @@
|
|||||||
1. add faster way for admins to login, like an invisible button in top left or something
|
1. FIX NO AUDIO DURING NOT YOUR TURN!!!
|
||||||
2. add discord bot typing thing for when someone requests a replay
|
2. make sure audio forwarding doesnt stop when you switch tabs
|
||||||
3. change replay title for ones requested from discord, something other than "requester driving rover"
|
3. add "reboot your own rover" feature
|
||||||
4. fix rover request spam queue cheat
|
1.
|
||||||
|
4. add faster way for admins to login, like an invisible button in top left or something
|
||||||
|
5. add discord bot typing thing for when someone requests a replay
|
||||||
|
6. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||||
|
7. fix rover request spam queue cheat
|
||||||
|
|
||||||
|
|
||||||
# relative pipe dreams:
|
# relative pipe dreams:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ overseerControl:
|
|||||||
profileImageUrl: "https://example.com/overseer.png"
|
profileImageUrl: "https://example.com/overseer.png"
|
||||||
gateIntervalMs: 2000
|
gateIntervalMs: 2000
|
||||||
heartbeatMs: 30000
|
heartbeatMs: 30000
|
||||||
|
postChatDelayMs: 20000
|
||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
@@ -119,12 +120,20 @@ socials:
|
|||||||
- id: "discord"
|
- id: "discord"
|
||||||
label: "Discord"
|
label: "Discord"
|
||||||
url: "https://discord.gg/your-invite"
|
url: "https://discord.gg/your-invite"
|
||||||
|
icon: "FaDiscord"
|
||||||
|
color: "#5865F2"
|
||||||
- id: "kofi"
|
- id: "kofi"
|
||||||
label: "Ko-fi"
|
label: "Ko-fi"
|
||||||
url: "https://ko-fi.com/your-handle"
|
url: "https://ko-fi.com/your-handle"
|
||||||
|
icon: "FaCoffee"
|
||||||
|
color: "#29ABE0"
|
||||||
- id: "wiki"
|
- id: "wiki"
|
||||||
label: "Wiki"
|
label: "Wiki"
|
||||||
url: "https://wiki.example.com"
|
url: "https://wiki.example.com"
|
||||||
|
icon: "FaBook"
|
||||||
|
color: "#475569"
|
||||||
- id: "throne"
|
- id: "throne"
|
||||||
label: "Throne"
|
label: "Throne"
|
||||||
url: "https://throne.me/yourname"
|
url: "https://throne.me/yourname"
|
||||||
|
icon: "FaCrown"
|
||||||
|
color: "#334155"
|
||||||
|
|||||||
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="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DqqTkkpY.js"></script>
|
<script type="module" crossorigin src="/assets/index-CboMb02p.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-v8lmO81Z.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BGy4iG0w.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_
|
|||||||
const DEFAULT_NAME = 'The Overseer';
|
const DEFAULT_NAME = 'The Overseer';
|
||||||
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
||||||
const DEFAULT_HEARTBEAT_MS = 30000;
|
const DEFAULT_HEARTBEAT_MS = 30000;
|
||||||
|
const DEFAULT_POST_CHAT_DELAY_MS = 20000;
|
||||||
const MIN_INTERVAL_MS = 250;
|
const MIN_INTERVAL_MS = 250;
|
||||||
const MAX_RUN_HISTORY = 100;
|
const MAX_RUN_HISTORY = 100;
|
||||||
const MAX_CHAT_CONTEXT = 12;
|
const MAX_CHAT_CONTEXT = 12;
|
||||||
@@ -19,6 +20,7 @@ module.exports = {
|
|||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DEFAULT_GATE_INTERVAL_MS,
|
DEFAULT_GATE_INTERVAL_MS,
|
||||||
DEFAULT_HEARTBEAT_MS,
|
DEFAULT_HEARTBEAT_MS,
|
||||||
|
DEFAULT_POST_CHAT_DELAY_MS,
|
||||||
MAX_RUN_HISTORY,
|
MAX_RUN_HISTORY,
|
||||||
MAX_CHAT_CONTEXT,
|
MAX_CHAT_CONTEXT,
|
||||||
MAX_BOT_CONTEXT,
|
MAX_BOT_CONTEXT,
|
||||||
|
|||||||
@@ -60,16 +60,28 @@ function buildConversation({ recentMessages, name }) {
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModelMessages({ systemPrompt, stateUpdate, memorySummary, conversationMessages, availableTools, blockedTools }) {
|
function buildModelMessages({
|
||||||
|
systemPrompt,
|
||||||
|
stateUpdate,
|
||||||
|
memorySummary,
|
||||||
|
recentEvents,
|
||||||
|
conversationMessages,
|
||||||
|
availableTools,
|
||||||
|
blockedTools,
|
||||||
|
}) {
|
||||||
const messages = [];
|
const messages = [];
|
||||||
messages.push({ role: 'system', content: systemPrompt });
|
messages.push({ role: 'system', content: systemPrompt });
|
||||||
const metadataSections = [];
|
messages.push({ role: 'system', content: `ROOM_SNAPSHOT\n${stateUpdate}` });
|
||||||
metadataSections.push(`STATE_UPDATE\n${stateUpdate}`);
|
if (memorySummary) {
|
||||||
if (memorySummary) metadataSections.push(`MEMORY_UPDATE\n${memorySummary}`);
|
messages.push({ role: 'system', content: `MEMORY_SUMMARY\n${memorySummary}` });
|
||||||
metadataSections.push(
|
}
|
||||||
`tool_constraints:\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
if (recentEvents) {
|
||||||
);
|
messages.push({ role: 'system', content: `RECENT_EVENTS\n${recentEvents}` });
|
||||||
messages.push({ role: 'user', content: metadataSections.join('\n\n') });
|
}
|
||||||
|
messages.push({
|
||||||
|
role: 'system',
|
||||||
|
content: `TOOL_CONSTRAINTS\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
||||||
|
});
|
||||||
(conversationMessages || []).forEach((message) => {
|
(conversationMessages || []).forEach((message) => {
|
||||||
if (!message || !message.role || !message.content) return;
|
if (!message || !message.role || !message.content) return;
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const {
|
|||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DEFAULT_GATE_INTERVAL_MS,
|
DEFAULT_GATE_INTERVAL_MS,
|
||||||
DEFAULT_HEARTBEAT_MS,
|
DEFAULT_HEARTBEAT_MS,
|
||||||
|
DEFAULT_POST_CHAT_DELAY_MS,
|
||||||
MAX_RUN_HISTORY,
|
MAX_RUN_HISTORY,
|
||||||
MAX_CHAT_CONTEXT,
|
MAX_CHAT_CONTEXT,
|
||||||
MAX_BOT_CONTEXT,
|
MAX_BOT_CONTEXT,
|
||||||
@@ -40,6 +41,7 @@ const model = String(overseerConfig.model || '').trim();
|
|||||||
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||||
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
||||||
|
const postChatDelayMs = normalizeMs(Number(overseerConfig.postChatDelayMs), DEFAULT_POST_CHAT_DELAY_MS);
|
||||||
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
||||||
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
||||||
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
||||||
@@ -67,6 +69,7 @@ let status = {
|
|||||||
promptPath: PROMPT_PATH,
|
promptPath: PROMPT_PATH,
|
||||||
gateIntervalMs,
|
gateIntervalMs,
|
||||||
heartbeatMs,
|
heartbeatMs,
|
||||||
|
postChatDelayMs,
|
||||||
alwaysRunModel,
|
alwaysRunModel,
|
||||||
postToolsOnlyMessages,
|
postToolsOnlyMessages,
|
||||||
running: false,
|
running: false,
|
||||||
@@ -290,6 +293,20 @@ function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRecentEventsSummary() {
|
||||||
|
const events = (runtime.liveToolCalls || [])
|
||||||
|
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
||||||
|
.slice(-3)
|
||||||
|
.map((entry) => {
|
||||||
|
const tool = String(entry.tool || 'unknown');
|
||||||
|
const phase = String(entry.phase || 'unknown');
|
||||||
|
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
|
||||||
|
return `- tool=${tool} phase=${phase}${err}`;
|
||||||
|
});
|
||||||
|
if (!events.length) return '- none';
|
||||||
|
return events.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
async function runDecision(triggerReason) {
|
async function runDecision(triggerReason) {
|
||||||
const runId = runtime.tickCount;
|
const runId = runtime.tickCount;
|
||||||
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||||
@@ -317,6 +334,7 @@ async function runDecision(triggerReason) {
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
stateUpdate,
|
stateUpdate,
|
||||||
memorySummary: summarizeMemory(runtime.memoryStore),
|
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||||
|
recentEvents: buildRecentEventsSummary(),
|
||||||
conversationMessages,
|
conversationMessages,
|
||||||
availableTools: toolState.available,
|
availableTools: toolState.available,
|
||||||
blockedTools: toolState.blocked,
|
blockedTools: toolState.blocked,
|
||||||
@@ -360,6 +378,7 @@ async function runDecision(triggerReason) {
|
|||||||
|
|
||||||
const actionResults = [];
|
const actionResults = [];
|
||||||
const requestedActions = toolCalls;
|
const requestedActions = toolCalls;
|
||||||
|
let postedChat = false;
|
||||||
let outcome = observeOnly ? 'observed' : 'executed';
|
let outcome = observeOnly ? 'observed' : 'executed';
|
||||||
const reason = observeOnly ? 'observe-only mode' : null;
|
const reason = observeOnly ? 'observe-only mode' : null;
|
||||||
|
|
||||||
@@ -399,11 +418,13 @@ async function runDecision(triggerReason) {
|
|||||||
if (toolCallFeed.length > 0) {
|
if (toolCallFeed.length > 0) {
|
||||||
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||||
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||||
|
postedChat = true;
|
||||||
} else if (postToolsOnlyMessages) {
|
} else if (postToolsOnlyMessages) {
|
||||||
sendSystemMessage('', { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
sendSystemMessage('', { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||||
}
|
}
|
||||||
} else if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
} else if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||||
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
|
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
|
||||||
|
postedChat = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +456,8 @@ async function runDecision(triggerReason) {
|
|||||||
generationMs,
|
generationMs,
|
||||||
blockedTools: toolState.blocked,
|
blockedTools: toolState.blocked,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { postedChat };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tick() {
|
async function tick() {
|
||||||
@@ -442,12 +465,16 @@ async function tick() {
|
|||||||
runtime.inFlight = true;
|
runtime.inFlight = true;
|
||||||
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
|
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
|
||||||
|
|
||||||
|
let nextDelayMs = gateIntervalMs;
|
||||||
try {
|
try {
|
||||||
const triggerReason = computeTriggerReason();
|
const triggerReason = computeTriggerReason();
|
||||||
if (!triggerReason) {
|
if (!triggerReason) {
|
||||||
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
|
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
|
||||||
} else {
|
} else {
|
||||||
await runDecision(triggerReason);
|
const runResult = await runDecision(triggerReason);
|
||||||
|
if (runResult?.postedChat) {
|
||||||
|
nextDelayMs = postChatDelayMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const failure = buildFailureInfo(err);
|
const failure = buildFailureInfo(err);
|
||||||
@@ -462,8 +489,8 @@ async function tick() {
|
|||||||
} finally {
|
} finally {
|
||||||
runtime.inFlight = false;
|
runtime.inFlight = false;
|
||||||
if (status.running) {
|
if (status.running) {
|
||||||
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs });
|
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
|
||||||
runtime.timer = setTimeout(tick, gateIntervalMs);
|
runtime.timer = setTimeout(tick, nextDelayMs);
|
||||||
} else {
|
} else {
|
||||||
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -23,7 +23,6 @@ import HomeAssistantControls from './components/HomeAssistantControls/index.jsx'
|
|||||||
import TurnAlertListener from './components/TurnAlertListener/index.jsx';
|
import TurnAlertListener from './components/TurnAlertListener/index.jsx';
|
||||||
import RawUserPilePanel from './components/RawUserPilePanel/index.jsx';
|
import RawUserPilePanel from './components/RawUserPilePanel/index.jsx';
|
||||||
import OverseerPreferencePanel from './components/OverseerPreferencePanel/index.jsx';
|
import OverseerPreferencePanel from './components/OverseerPreferencePanel/index.jsx';
|
||||||
import NicknameForm from './components/NicknameForm/index.jsx';
|
|
||||||
import SocialButtonsGrid from './components/SocialButtonsGrid/index.jsx';
|
import SocialButtonsGrid from './components/SocialButtonsGrid/index.jsx';
|
||||||
import ChatPanel from './components/ChatPanel/index.jsx';
|
import ChatPanel from './components/ChatPanel/index.jsx';
|
||||||
import FullscreenPrompt from './components/FullscreenPrompt/index.jsx';
|
import FullscreenPrompt from './components/FullscreenPrompt/index.jsx';
|
||||||
@@ -144,12 +143,9 @@ function MobileFeatureTabs({
|
|||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel id="chat">
|
<TabPanel id="chat">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ChatPanel />
|
<ChatPanel nicknameLayout="stacked" />
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
<div className="surface flex w-full items-center px-0 py-0">
|
|
||||||
<NicknameForm />
|
|
||||||
</div>
|
|
||||||
<SocialButtonsGrid />
|
<SocialButtonsGrid />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSocket } from '../../context/SocketContext.jsx';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
const FLASH_MS = 420;
|
const FLASH_MS = 420;
|
||||||
const REWARD_FLASH_MS = 1200;
|
const REWARD_FLASH_MS = 1200;
|
||||||
@@ -126,10 +127,7 @@ export default function ButtonBoxPanel() {
|
|||||||
}, [effectiveAlertVolume, socket]);
|
}, [effectiveAlertVolume, socket]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
|
||||||
<header className="panel-muted text-center text-sm">
|
|
||||||
<p>Button Box</p>
|
|
||||||
</header>
|
|
||||||
<div className="grid grid-cols-4 gap-0.5">
|
<div className="grid grid-cols-4 gap-0.5">
|
||||||
{buttons.map((button) => {
|
{buttons.map((button) => {
|
||||||
const id = Number(button.id);
|
const id = Number(button.id);
|
||||||
@@ -155,6 +153,6 @@ export default function ButtonBoxPanel() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
function cx(...values) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color helpers
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const raw = String(hex || '').trim();
|
||||||
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||||
|
const expanded =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component
|
||||||
|
export default function CardFrame({
|
||||||
|
title = '',
|
||||||
|
meta = null,
|
||||||
|
actions = null,
|
||||||
|
hideHeader = false,
|
||||||
|
className = '',
|
||||||
|
headerClassName = '',
|
||||||
|
bodyClassName = '',
|
||||||
|
fillHeight = false,
|
||||||
|
clipOverflow = true,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const showHeader = !hideHeader && (title || meta != null || actions);
|
||||||
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
|
||||||
|
const headerStyle = accentRgb
|
||||||
|
? {
|
||||||
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cx(
|
||||||
|
'panel-section border border-neutral-500/60 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
|
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
|
||||||
|
fillHeight && 'flex h-full min-h-0 flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={cardStyle}
|
||||||
|
>
|
||||||
|
{showHeader ? (
|
||||||
|
// Header row
|
||||||
|
<header
|
||||||
|
className={cx(
|
||||||
|
'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
|
||||||
|
headerClassName,
|
||||||
|
)}
|
||||||
|
style={headerStyle}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-0.5">
|
||||||
|
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
|
||||||
|
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className={cx(fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||||
import ChatTypingRow from '../ChatTypingRow/index.jsx';
|
import ChatTypingRow from '../ChatTypingRow/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
import NicknameForm from '../NicknameForm/index.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);
|
||||||
@@ -16,6 +18,8 @@ export default function ChatPanel({
|
|||||||
hideSpectatorNotice = false,
|
hideSpectatorNotice = false,
|
||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
allowSpectatorInput = false,
|
allowSpectatorInput = false,
|
||||||
|
title = 'Chat and TTS',
|
||||||
|
nicknameLayout = 'inline',
|
||||||
}) {
|
}) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
@@ -103,9 +107,16 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
||||||
|
const isStackedNickname = nicknameLayout === 'stacked';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
<CardFrame
|
||||||
|
title={title}
|
||||||
|
|
||||||
|
hideHeader={!title}
|
||||||
|
fillHeight={fillHeight}
|
||||||
|
bodyClassName="space-y-0.5 text-base"
|
||||||
|
>
|
||||||
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
||||||
{sorted.length === 0 && typingRows.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>
|
||||||
@@ -117,9 +128,15 @@ export default function ChatPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!hideInput && (
|
{!hideInput && (
|
||||||
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
|
<form
|
||||||
|
className={`flex items-stretch gap-0.5 ${isStackedNickname ? 'flex-col' : 'flex-wrap'}`}
|
||||||
|
onSubmit={handleSend}
|
||||||
|
>
|
||||||
|
<div className={isStackedNickname ? 'w-full' : 'w-[9rem] sm:w-[10rem] shrink-0'}>
|
||||||
|
<NicknameForm compact />
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1 min-w-[10rem]"
|
className={`field-input ${isStackedNickname ? 'w-full min-w-0' : 'flex-1 min-w-[10rem]'}`}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
@@ -146,7 +163,11 @@ export default function ChatPanel({
|
|||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
/>
|
/>
|
||||||
{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 ${
|
||||||
|
isStackedNickname ? 'w-full' : 'basis-full sm:basis-auto'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<label className="flex items-center gap-0.5 text-xs text-slate-300">
|
<label className="flex items-center gap-0.5 text-xs text-slate-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -206,12 +227,12 @@ export default function ChatPanel({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canChat || sending}
|
disabled={!canChat || sending}
|
||||||
className="button-dark h-full disabled:opacity-50 self-stretch"
|
className={`button-dark disabled:opacity-50 ${isStackedNickname ? 'w-full h-8' : 'h-full self-stretch'}`}
|
||||||
>
|
>
|
||||||
{sending ? '...' : 'Send'}
|
{sending ? '...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,40 @@ const MOBILE_DISMISS_MS = 10000;
|
|||||||
const MAX_FONT_PX = 28;
|
const MAX_FONT_PX = 28;
|
||||||
const MIN_FONT_PX = 14;
|
const MIN_FONT_PX = 14;
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const raw = String(hex || '').trim();
|
||||||
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||||
|
const expanded =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
||||||
const goalText = useSessionSelector((state) => {
|
const goalText = useSessionSelector((state) => {
|
||||||
const text = state.session?.globalObjective?.text;
|
const text = state.session?.globalObjective?.text;
|
||||||
return text ? String(text).trim() : '';
|
return text ? String(text).trim() : '';
|
||||||
});
|
});
|
||||||
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
||||||
@@ -72,7 +101,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
const containerClass = useMemo(
|
const containerClass = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
'panel-section flex w-full items-center justify-center',
|
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
isMobile ? 'rounded-none' : 'rounded',
|
isMobile ? 'rounded-none' : 'rounded',
|
||||||
'px-1 py-1 text-center font-semibold tracking-tight',
|
'px-1 py-1 text-center font-semibold tracking-tight',
|
||||||
className,
|
className,
|
||||||
@@ -81,6 +110,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
.join(' '),
|
.join(' '),
|
||||||
[className, isMobile],
|
[className, isMobile],
|
||||||
);
|
);
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const frameStyle = accentRgb
|
||||||
|
? {
|
||||||
|
borderColor: rgba(accentRgb, 0.35),
|
||||||
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!goalText || !visible) return null;
|
if (!goalText || !visible) return null;
|
||||||
|
|
||||||
@@ -94,7 +130,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={containerClass}
|
className={containerClass}
|
||||||
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
||||||
{...dismissProps}
|
{...dismissProps}
|
||||||
>
|
>
|
||||||
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
|
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useControlSystem } from '../../controls/index.js';
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function StatusBadge({ label, tone = 'muted' }) {
|
function StatusBadge({ label, tone = 'muted' }) {
|
||||||
const styles =
|
const styles =
|
||||||
@@ -255,44 +256,41 @@ export default function HomeAssistantControls() {
|
|||||||
|
|
||||||
if (!ha?.enabled) {
|
if (!ha?.enabled) {
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||||
<p className="text-slate-300">Room Controls</p>
|
|
||||||
<p className="text-slate-500">Not configured on the server.</p>
|
<p className="text-slate-500">Not configured on the server.</p>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entities.length === 0) {
|
if (entities.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||||
<p className="text-slate-300">Room Controls</p>
|
|
||||||
<p className="text-slate-500">No lights or switches configured.</p>
|
<p className="text-slate-500">No lights or switches configured.</p>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = Boolean(ha?.connected);
|
const connected = Boolean(ha?.connected);
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<span>On</span>
|
||||||
|
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<span>Off</span>
|
||||||
|
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
||||||
|
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
|
||||||
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<p>Room Controls</p>
|
|
||||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
|
||||||
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
|
||||||
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<span>On</span>
|
|
||||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<span>Off</span>
|
|
||||||
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
|
||||||
</header>
|
|
||||||
{controlsLocked ? (
|
{controlsLocked ? (
|
||||||
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||||
{lockState === 'off'
|
{lockState === 'off'
|
||||||
@@ -313,11 +311,11 @@ export default function HomeAssistantControls() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyPill({ label }) {
|
function KeyPill({ label }) {
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
return <span className="rounded border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import SocialButton from '../../SocialButton/index.jsx';
|
|||||||
function TurnsOverlay({
|
function TurnsOverlay({
|
||||||
roverId = null,
|
roverId = null,
|
||||||
mobileHud = false,
|
mobileHud = false,
|
||||||
discordUrl: discordUrlProp = null,
|
|
||||||
}) {
|
}) {
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
@@ -16,19 +15,9 @@ function TurnsOverlay({
|
|||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||||
const discordUrl = useSessionSelector((state) => {
|
|
||||||
const socials = state.session?.socials || [];
|
|
||||||
const socialUrl =
|
|
||||||
socials.find((entry) => {
|
|
||||||
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
|
||||||
return key === 'discord';
|
|
||||||
})?.url || null;
|
|
||||||
return socialUrl || state.session?.discord?.invite || null;
|
|
||||||
});
|
|
||||||
const {
|
const {
|
||||||
state: { lastControlIntentAt },
|
state: { lastControlIntentAt },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const effectiveDiscordUrl = discordUrlProp || discordUrl;
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||||
@@ -214,11 +203,7 @@ function TurnsOverlay({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="pointer-events-auto mt-0.5">
|
<div className="pointer-events-auto mt-0.5">
|
||||||
<SocialButton
|
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/>
|
||||||
id="discord"
|
|
||||||
label="Join our Discord while you wait!"
|
|
||||||
url={effectiveDiscordUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,30 +3,27 @@
|
|||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
export default function LogPanel() {
|
export default function LogPanel() {
|
||||||
const logs = useSessionSelector((state) => state.logs);
|
const logs = useSessionSelector((state) => state.logs);
|
||||||
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
|
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
|
||||||
return (
|
return (
|
||||||
<div className="panel-section space-y-0.5 text-base">
|
<CardFrame title="Server logs" bodyClassName="space-y-0.5 text-base">
|
||||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
|
||||||
<span>Server logs</span>
|
|
||||||
<span>{logs.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p>No logs yet.</p>
|
<p>No logs yet.</p>
|
||||||
) : (
|
) : (
|
||||||
rendered.map((entry) => (
|
rendered.map((entry) => (
|
||||||
<div key={entry.id} className="surface">
|
<div key={entry.id} className="surface">
|
||||||
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
||||||
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
||||||
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||||
<span>{entry.message}</span>
|
<span>{entry.message}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import AuthPanel from '../AuthPanel/index.jsx';
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import SocialButton from '../SocialButton/index.jsx';
|
import SocialButton from '../SocialButton/index.jsx';
|
||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
import ChatPanel from '../ChatPanel/index.jsx';
|
||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
|
||||||
|
|
||||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||||
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
|
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
|
||||||
@@ -33,14 +32,6 @@ export default function ModeGateOverlay() {
|
|||||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
||||||
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
|
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
|
||||||
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
||||||
const discordUrl = useSessionSelector((state) => {
|
|
||||||
const socials = state.session?.socials || [];
|
|
||||||
const fromSocials = socials.find((entry) => {
|
|
||||||
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
|
||||||
return key === 'discord';
|
|
||||||
})?.url;
|
|
||||||
return fromSocials || state.session?.discord?.invite || null;
|
|
||||||
});
|
|
||||||
const restricted = RESTRICTED_MODES.has(mode);
|
const restricted = RESTRICTED_MODES.has(mode);
|
||||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
@@ -92,17 +83,12 @@ export default function ModeGateOverlay() {
|
|||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full justify-center items-center">
|
<div className="w-full justify-center items-center">
|
||||||
<SocialButton
|
<SocialButton id="discord" label="Join our Discord server for updates!"/>
|
||||||
id="discord"
|
|
||||||
label="Join our Discord server for updates!"
|
|
||||||
url={discordUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
You can still use the chat while the server is locked:
|
You can still use the chat while the server is locked:
|
||||||
{/* set max height of this box */}
|
{/* set max height of this box */}
|
||||||
<div className='max-h-80 overflow-y-auto'>
|
<div className='max-h-80 overflow-y-auto'>
|
||||||
<ChatPanel />
|
<ChatPanel nicknameLayout="stacked" />
|
||||||
<NicknameForm />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <p className="text-xs text-slate-500">
|
{/* <p className="text-xs text-slate-500">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
export default function OverseerPreferencePanel() {
|
export default function OverseerPreferencePanel() {
|
||||||
const { identifySession } = useSessionActions();
|
const { identifySession } = useSessionActions();
|
||||||
@@ -21,8 +22,7 @@ export default function OverseerPreferencePanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="surface p-1 text-xs text-slate-200 text-center">
|
<CardFrame title="Overseer vote" bodyClassName="space-y-0.5 text-center text-xs text-slate-200">
|
||||||
<div className="text-[0.65rem] text-slate-400">Overseer vote</div>
|
|
||||||
<label
|
<label
|
||||||
className={`flex items-center justify-center gap-1.5 rounded px-1 py-0.5 ${
|
className={`flex items-center justify-center gap-1.5 rounded px-1 py-0.5 ${
|
||||||
enabled ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
|
enabled ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
|
||||||
@@ -33,9 +33,9 @@ export default function OverseerPreferencePanel() {
|
|||||||
</label>
|
</label>
|
||||||
<span className="text-xs">Enable a local LLM in chat</span>
|
<span className="text-xs">Enable a local LLM in chat</span>
|
||||||
|
|
||||||
{/* <div className="mt-0.5 text-slate-400">
|
<div className="mt-0.5 text-slate-400">
|
||||||
Yes!: {Number(vote?.yesCount || 0)}, No...: {Number(vote?.noCount || 0)}
|
Yes!: {Number(vote?.yesCount || 0)}, No...: {Number(vote?.noCount || 0)}
|
||||||
</div> */}
|
</div>
|
||||||
<div className="mt-0.5 flex justify-center">
|
<div className="mt-0.5 flex justify-center">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[0.7rem] font-medium ${
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[0.7rem] font-medium ${
|
||||||
@@ -45,6 +45,6 @@ export default function OverseerPreferencePanel() {
|
|||||||
{running ? 'Running!' : 'Stopped by vote.'}
|
{running ? 'Running!' : 'Stopped by vote.'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
|||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
import SocialButton from '../SocialButton/index.jsx';
|
import SocialButton from '../SocialButton/index.jsx';
|
||||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|
||||||
|
|
||||||
function ControlRow({ label, keyLabel }) {
|
function ControlRow({ label, keyLabel }) {
|
||||||
return (
|
return (
|
||||||
@@ -56,15 +55,6 @@ export default function QuickstartOverlay({
|
|||||||
}) {
|
}) {
|
||||||
const { state } = useControlSystem();
|
const { state } = useControlSystem();
|
||||||
const isDesktop = layout === 'desktop';
|
const isDesktop = layout === 'desktop';
|
||||||
const discordUrl = useSessionSelector((sessionState) => {
|
|
||||||
const socials = sessionState.session?.socials || [];
|
|
||||||
const entry = socials.find((item) => {
|
|
||||||
const key = String(item?.id || item?.label || '').toLowerCase();
|
|
||||||
return key === 'discord';
|
|
||||||
});
|
|
||||||
return entry?.url || sessionState.session?.discord?.invite || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const keymap = useMemo(() => state?.keymap || {}, [state?.keymap]);
|
const keymap = useMemo(() => state?.keymap || {}, [state?.keymap]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
@@ -98,7 +88,7 @@ export default function QuickstartOverlay({
|
|||||||
<div className="surface p-0.5">
|
<div className="surface p-0.5">
|
||||||
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
||||||
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
||||||
<SocialButton id="discord" label="Join Discord" url={discordUrl} />
|
<SocialButton id="discord" label="Join Discord" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function roleColors(role) {
|
function roleColors(role) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@@ -57,8 +58,13 @@ export default function RawUserPilePanel({
|
|||||||
: 'h-48 overflow-y-auto';
|
: 'h-48 overflow-y-auto';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<CardFrame
|
||||||
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
|
title={!hideHeader ? 'Users' : ''}
|
||||||
|
|
||||||
|
hideHeader={hideHeader}
|
||||||
|
fillHeight={fillHeight}
|
||||||
|
className={className}
|
||||||
|
bodyClassName="space-y-0.5 text-base"
|
||||||
>
|
>
|
||||||
{!hideNicknameForm && (
|
{!hideNicknameForm && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -75,12 +81,6 @@ export default function RawUserPilePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<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 && (
|
|
||||||
<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
|
<div
|
||||||
className={`surface flex flex-wrap content-start items-start gap-0.5 px-0 pb-0 ${baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}
|
className={`surface flex flex-wrap content-start items-start gap-0.5 px-0 pb-0 ${baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -98,6 +98,6 @@ export default function RawUserPilePanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function normalizeSources(list = []) {
|
function normalizeSources(list = []) {
|
||||||
return list
|
return list
|
||||||
@@ -146,15 +147,10 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerClass = fillHeight ? 'h-full flex flex-col' : '';
|
|
||||||
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel-section p-0.75 text-sm ${containerClass}`}>
|
<CardFrame title="Replay Sources" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
|
||||||
<header className="panel-muted flex items-center justify-between text-xs">
|
|
||||||
<span>Replay Sources</span>
|
|
||||||
<span>{sources.length}</span>
|
|
||||||
</header>
|
|
||||||
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
|
<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} />
|
||||||
@@ -201,7 +197,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||||
{success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
{success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
|||||||
import SettingsPanel from '../SettingsPanel/index.jsx';
|
import SettingsPanel from '../SettingsPanel/index.jsx';
|
||||||
import HelpPanel from '../HelpPanel/index.jsx';
|
import HelpPanel from '../HelpPanel/index.jsx';
|
||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
import ChatPanel from '../ChatPanel/index.jsx';
|
||||||
import { LinkButtonsPanel, NicknameEntryPanel } from '../UserListPanel/index.jsx';
|
import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
|
||||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
||||||
import TopDownMap from '../TopDownMap/index.jsx';
|
import TopDownMap from '../TopDownMap/index.jsx';
|
||||||
@@ -24,6 +24,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
@@ -35,11 +36,11 @@ function TopDownMapPanel() {
|
|||||||
const sensors = frame?.sensors || {};
|
const sensors = frame?.sensors || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section">
|
<CardFrame hideHeader>
|
||||||
<div className="aspect-square w-full">
|
<div className="aspect-square w-full">
|
||||||
<TopDownMap sensors={sensors} />
|
<TopDownMap sensors={sensors} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ function DriveDockPanel() {
|
|||||||
value={value}
|
value={value}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
label="Camera tilt"
|
||||||
disabled={cameraDisabled}
|
disabled={cameraDisabled}
|
||||||
onChange={setServoAngle}
|
onChange={setServoAngle}
|
||||||
keyDownLabel={downLabel}
|
keyDownLabel={downLabel}
|
||||||
@@ -178,10 +180,9 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
|
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
|
||||||
<ChatPanel fillHeight />
|
<ChatPanel fillHeight />
|
||||||
<div className="grid min-h-0 gap-0.5 grid-rows-[auto_minmax(0,1fr)_auto]">
|
<div className="grid min-h-0 gap-0.5 grid-rows-[auto_minmax(0,1fr)]">
|
||||||
<OverseerPreferencePanel />
|
<OverseerPreferencePanel />
|
||||||
<RawUserPilePanel compact hideNicknameForm fillHeight />
|
<RawUserPilePanel compact hideNicknameForm fillHeight />
|
||||||
<NicknameEntryPanel compact />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HomeAssistantControls />
|
<HomeAssistantControls />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||||
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
|
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
@@ -66,35 +67,32 @@ export default function RoomCameraPanel({
|
|||||||
return <EmptyState />;
|
return <EmptyState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actions = showLayoutToggle ? (
|
||||||
|
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
|
||||||
|
<span>Layout</span>
|
||||||
|
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||||
|
{ORIENTATIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||||
|
onClick={() => applyOrientation(option)}
|
||||||
|
>
|
||||||
|
{option === 'vertical' ? 'Vertical' : 'Grid'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<CardFrame
|
||||||
{!hideHeader && (
|
title="Room cameras"
|
||||||
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
|
||||||
<div className="flex items-center gap-0.5">
|
actions={actions}
|
||||||
<p>Room cameras</p>
|
hideHeader={hideHeader}
|
||||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
bodyClassName="space-y-0.5 text-base"
|
||||||
</div>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-0.5 text-xs">
|
|
||||||
{showLayoutToggle && (
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<span className="text-slate-500">Layout:</span>
|
|
||||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
|
||||||
{ORIENTATIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
type="button"
|
|
||||||
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
|
||||||
onClick={() => applyOrientation(option)}
|
|
||||||
>
|
|
||||||
{option === 'vertical' ? 'Vertical' : 'Grid'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
const feed = feedMap[camera.id] || null;
|
const feed = feedMap[camera.id] || null;
|
||||||
@@ -109,6 +107,6 @@ export default function RoomCameraPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function classNames(...values) {
|
function classNames(...values) {
|
||||||
return values.filter(Boolean).join(' ');
|
return values.filter(Boolean).join(' ');
|
||||||
@@ -96,8 +97,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5 text-sm">
|
<CardFrame title={title} bodyClassName="space-y-0.5 text-sm">
|
||||||
{title && <p className="text-sm text-slate-400">{title}</p>}
|
|
||||||
{rosterItems.length === 0 ? (
|
{rosterItems.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -216,6 +216,6 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,59 @@
|
|||||||
// Social Button
|
// Social Button
|
||||||
// Purpose: Defines the Social Button module and the local helpers/components used in this file.
|
// Purpose: Defines the Social Button module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useEffect } from 'react';
|
import * as FaIcons from 'react-icons/fa';
|
||||||
import { FaBook, FaCoffee, FaCrown, FaDiscord, FaLink } from 'react-icons/fa';
|
import { FaLink } from 'react-icons/fa';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { getSocialById } from '../../lib/socials.js';
|
||||||
|
|
||||||
const ICONS_BY_ID = {
|
function sanitizeCssColor(value) {
|
||||||
discord: FaDiscord,
|
if (typeof value !== 'string') return null;
|
||||||
kofi: FaCoffee,
|
const trimmed = value.trim();
|
||||||
'ko-fi': FaCoffee,
|
if (!trimmed) return null;
|
||||||
wiki: FaBook,
|
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||||
throne: FaCrown,
|
if (/^(rgb|rgba|hsl|hsla)\([^)]+\)$/.test(trimmed)) return trimmed;
|
||||||
};
|
return null;
|
||||||
|
|
||||||
const GRADIENT_STYLE_BY_ID = {
|
|
||||||
discord: {
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(270deg,#5865F2,#404EED,#5865F2)',
|
|
||||||
backgroundSize: '600% 600%',
|
|
||||||
animation: 'socialGradient 180s ease infinite',
|
|
||||||
},
|
|
||||||
kofi: {
|
|
||||||
backgroundImage: 'linear-gradient(270deg,#FF6433,#E04822,#FF6433)',
|
|
||||||
backgroundSize: '600% 600%',
|
|
||||||
animation: 'socialGradient 170s ease infinite',
|
|
||||||
},
|
|
||||||
'ko-fi': {
|
|
||||||
backgroundImage: 'linear-gradient(270deg,#FF6433,#E04822,#FF6433)',
|
|
||||||
backgroundSize: '600% 600%',
|
|
||||||
animation: 'socialGradient 170s ease infinite',
|
|
||||||
},
|
|
||||||
wiki: {
|
|
||||||
backgroundImage: 'linear-gradient(270deg,#EE8019,#C65C08,#EE8019)',
|
|
||||||
backgroundSize: '600% 600%',
|
|
||||||
animation: 'socialGradient 180s ease infinite',
|
|
||||||
},
|
|
||||||
throne: {
|
|
||||||
backgroundImage: 'linear-gradient(270deg,#7C3AED,#5B21B6,#7C3AED)',
|
|
||||||
backgroundSize: '600% 600%',
|
|
||||||
animation: 'socialGradient 180s ease infinite',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function useSocialButtonStyles() {
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
const styleId = 'social-button-keyframes';
|
|
||||||
if (document.getElementById(styleId)) return;
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = styleId;
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes socialGradient {
|
|
||||||
0% { background-position: 0% 50%; }
|
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
100% { background-position: 0% 50%; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeId(id, label) {
|
function resolveIcon(iconName) {
|
||||||
if (typeof id === 'string' && id.trim()) return id.trim().toLowerCase();
|
if (typeof iconName !== 'string' || !iconName.trim()) return FaLink;
|
||||||
if (typeof label === 'string' && label.trim()) {
|
const icon = FaIcons[iconName.trim()];
|
||||||
return label
|
return typeof icon === 'function' ? icon : FaLink;
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/[^a-z0-9-]/g, '');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SocialButton({ id, label, url, className = '' }) {
|
export default function SocialButton({ id = null, label, url, icon, color, layout = 'stacked', className = '' }) {
|
||||||
if (!url) return null;
|
const socialFromId = useSessionSelector((state) => (id ? getSocialById(state, id) : null));
|
||||||
useSocialButtonStyles();
|
const resolvedUrl = url || socialFromId?.url || null;
|
||||||
const key = normalizeId(id, label);
|
if (!resolvedUrl) return null;
|
||||||
const Icon = ICONS_BY_ID[key] || FaLink;
|
const resolvedIcon = icon || socialFromId?.icon || null;
|
||||||
const gradientStyle = GRADIENT_STYLE_BY_ID[key] || null;
|
const resolvedColor = color || socialFromId?.color || null;
|
||||||
const text = label || id || 'Link';
|
const Icon = resolveIcon(resolvedIcon);
|
||||||
|
const bgColor = sanitizeCssColor(resolvedColor);
|
||||||
|
const text = label || socialFromId?.label || 'Link';
|
||||||
|
const isInline = layout === 'inline';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={resolvedUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={text}
|
aria-label={text}
|
||||||
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white transition justify-center gap-1 rounded-md ${
|
className={`grid h-full min-h-0 w-full place-items-center rounded-md bg-slate-700 px-0.5 ${isInline ? 'py-0.5' : 'pb-3'} text-center text-sm font-medium text-white transition hover:opacity-90 ${className}`}
|
||||||
gradientStyle ? '' : 'bg-slate-700 hover:bg-slate-600'
|
style={bgColor ? { backgroundColor: bgColor } : undefined}
|
||||||
} ${className}`}
|
|
||||||
style={gradientStyle || undefined}
|
|
||||||
>
|
>
|
||||||
<Icon className="mr-0" />
|
{isInline ? (
|
||||||
{text}
|
<span className="inline-flex max-w-full items-center justify-center gap-1 text-center leading-tight">
|
||||||
|
<Icon className="shrink-0" style={{ fontSize: '1.1em' }} />
|
||||||
|
<span className="break-words">{text}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-full w-full max-w-full flex-col items-center justify-between text-center leading-tight">
|
||||||
|
<span className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<Icon className="shrink-0" style={{ fontSize: 'clamp(1rem, 2.2vh + 1.2vw, 3rem)' }} />
|
||||||
|
</span>
|
||||||
|
<span className="w-full break-words leading-tight">{text}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,46 +4,18 @@
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import SocialButton from '../SocialButton/index.jsx';
|
import SocialButton from '../SocialButton/index.jsx';
|
||||||
|
|
||||||
function normalizeSocials({ socials, discordInvite, kofiLink }) {
|
|
||||||
const configured = Array.isArray(socials) ? socials : null;
|
|
||||||
if (configured && configured.length) {
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
const fallback = [];
|
|
||||||
if (discordInvite) {
|
|
||||||
fallback.push({ id: 'discord', label: 'Discord', url: discordInvite });
|
|
||||||
}
|
|
||||||
if (kofiLink) {
|
|
||||||
fallback.push({ id: 'kofi', label: 'Ko-fi', url: kofiLink });
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEntry(entry) {
|
|
||||||
if (!entry || typeof entry !== 'object') return null;
|
|
||||||
const id = entry.id || entry.service || entry.key || entry.name || null;
|
|
||||||
const label = entry.label || entry.title || entry.name || id || 'Link';
|
|
||||||
const url = entry.url || entry.link || entry.href || null;
|
|
||||||
return url ? { id, label, url } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SocialButtonsGrid({ className = '' }) {
|
export default function SocialButtonsGrid({ className = '' }) {
|
||||||
const socialsInput = useSessionSelector((state) => ({
|
const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4);
|
||||||
socials: state.session?.socials ?? [],
|
|
||||||
discordInvite: state.session?.discord?.invite || null,
|
|
||||||
kofiLink: state.session?.kofi?.link || null,
|
|
||||||
}));
|
|
||||||
const socials = normalizeSocials(socialsInput)
|
|
||||||
.map(normalizeEntry)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
if (!socials.length) return null;
|
if (!socials.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
|
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
|
||||||
{socials.map((entry) => (
|
{socials.map((entry) => (
|
||||||
<SocialButton key={`${entry.id || entry.label}-${entry.url}`} {...entry} />
|
<SocialButton
|
||||||
|
key={`${entry?.id || entry?.label || 'social'}-${entry?.url || ''}`}
|
||||||
|
id={entry?.id || entry?.key || entry?.service || null}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
|
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
const TabsContext = createContext(null);
|
const TabsContext = createContext(null);
|
||||||
|
|
||||||
@@ -33,6 +34,28 @@ function classNames(...parts) {
|
|||||||
return parts.filter(Boolean).join(' ');
|
return parts.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const raw = String(hex || '').trim();
|
||||||
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||||
|
const expanded =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Tabs({ children, defaultTab, currentTab, onTabChange, variant = DEFAULT_VARIANT }) {
|
export default function Tabs({ children, defaultTab, currentTab, onTabChange, variant = DEFAULT_VARIANT }) {
|
||||||
const [internalTab, setInternalTab] = useState(defaultTab ?? null);
|
const [internalTab, setInternalTab] = useState(defaultTab ?? null);
|
||||||
const [tabOrder, setTabOrder] = useState([]);
|
const [tabOrder, setTabOrder] = useState([]);
|
||||||
@@ -95,7 +118,32 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TabList({ children, className = '' }) {
|
export function TabList({ children, className = '' }) {
|
||||||
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const frameStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
|
||||||
|
const headerStyle = accentRgb
|
||||||
|
? {
|
||||||
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'panel-section overflow-hidden border border-neutral-500/60 bg-neutral-900/95 p-0.5 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={frameStyle}
|
||||||
|
>
|
||||||
|
<div className="flex gap-0.5" style={headerStyle}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
|
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||||
import { useDockIr } from '../../hooks/useDockIr.js';
|
import { useDockIr } from '../../hooks/useDockIr.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function formatMetric(value, fallback = '--') {
|
function formatMetric(value, fallback = '--') {
|
||||||
if (value == null || value === '') return fallback;
|
if (value == null || value === '') return fallback;
|
||||||
@@ -39,7 +40,7 @@ export default function TelemetryPanel() {
|
|||||||
}, [activeDriverId, roverId, selfSocketId, users]);
|
}, [activeDriverId, roverId, selfSocketId, users]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
<CardFrame hideHeader clipOverflow={false} bodyClassName="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>
|
||||||
@@ -60,7 +61,7 @@ export default function TelemetryPanel() {
|
|||||||
{rawSnippet && (
|
{rawSnippet && (
|
||||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ function SensorDetails({ sensors, dockState }) {
|
|||||||
function DetailCard({ title, children }) {
|
function DetailCard({ title, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="surface space-y-0.5 p-1 text-sm">
|
<div className="surface space-y-0.5 p-1 text-sm">
|
||||||
<div className="text-[0.8rem] uppercase tracking-wide text-slate-400">{title}</div>
|
<div className="text-[0.78rem] font-semibold leading-none text-slate-200">{title}</div>
|
||||||
<div className="space-y-0.5">{children}</div>
|
<div className="space-y-0.5">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,12 +68,6 @@ function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize,
|
|||||||
className={`${overlay ? 'relative' : 'surface relative p-1'}`}
|
className={`${overlay ? 'relative' : 'surface relative p-1'}`}
|
||||||
style={overlay ? { width: `${size}px`, height: `${size}px` } : { height: '100%', width: '100%', aspectRatio: '1 / 1' }}
|
style={overlay ? { width: `${size}px`, height: `${size}px` } : { height: '100%', width: '100%', aspectRatio: '1 / 1' }}
|
||||||
>
|
>
|
||||||
{!overlay ? (
|
|
||||||
<>
|
|
||||||
<div className="absolute left-1 top-1 text-xs text-slate-400">Top-down</div>
|
|
||||||
<div className="absolute right-1 top-1 text-[0.65rem] text-slate-500">0–{maxLight}</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
|
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
|
||||||
<circle cx={centerX} cy={centerY} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
<circle cx={centerX} cy={centerY} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
||||||
<WheelVisual cx={centerX - wheelLineOffset} cy={centerY} current={wheelCurrentLeft} drop={bumps.wheelDropLeft} overcurrent={wheelOver.leftWheel} label="L" />
|
<WheelVisual cx={centerX - wheelLineOffset} cy={centerY} current={wheelCurrentLeft} drop={bumps.wheelDropLeft} overcurrent={wheelOver.leftWheel} label="L" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
||||||
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
|
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
export function NicknameEntryPanel({ compact = false }) {
|
export function NicknameEntryPanel({ compact = false }) {
|
||||||
return (
|
return (
|
||||||
@@ -19,9 +20,9 @@ export function NicknameEntryPanel({ compact = false }) {
|
|||||||
|
|
||||||
export function LinkButtonsPanel() {
|
export function LinkButtonsPanel() {
|
||||||
return (
|
return (
|
||||||
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
<CardFrame title="Links!" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
|
||||||
<SocialButtonsGrid className="flex-1 min-h-0" />
|
<SocialButtonsGrid className="flex-1 min-h-0" />
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||||
import StatusIndicator from './StatusIndicator.jsx';
|
import StatusIndicator from './StatusIndicator.jsx';
|
||||||
import KeyPill from './KeyPill.jsx';
|
import KeyPill from './KeyPill.jsx';
|
||||||
|
import CardFrame from '../../CardFrame/index.jsx';
|
||||||
|
|
||||||
export default function VipAudioUploadCard({
|
export default function VipAudioUploadCard({
|
||||||
ownRoverId = '',
|
ownRoverId = '',
|
||||||
@@ -529,9 +530,8 @@ export default function VipAudioUploadCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="surface">
|
<CardFrame title="Audio Controls">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
<p className="text-sm text-slate-100 text-center">Audio Controls</p>
|
|
||||||
<section className="surface">
|
<section className="surface">
|
||||||
<div className="flex items-center justify-center gap-0.5 py-0.25 text-xs text-slate-300">
|
<div className="flex items-center justify-center gap-0.5 py-0.25 text-xs text-slate-300">
|
||||||
<span>Push-to-Talk Key</span>
|
<span>Push-to-Talk Key</span>
|
||||||
@@ -653,6 +653,6 @@ export default function VipAudioUploadCard({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Defines the Vip Identity Card module and the local helpers/components used in this file.
|
// Purpose: Defines the Vip Identity Card module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { fieldClass, flowWrapClass, innerFlowClass, maskKey } from './constants.js';
|
import { fieldClass, flowWrapClass, innerFlowClass, maskKey } from './constants.js';
|
||||||
|
|
||||||
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage, fullWidth = false }) {
|
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage, fullWidth = false }) {
|
||||||
@@ -40,9 +41,8 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
|
|||||||
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`surface ${wrapClass}`}>
|
<CardFrame title="Identity key" className={wrapClass}>
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
<p className="text-sm text-slate-300">Identity key</p>
|
|
||||||
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
|
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
|
||||||
<input
|
<input
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
@@ -109,6 +109,6 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
|
|||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Renders a shared lift controller panel synced from server session state.
|
// Purpose: Renders a shared lift controller panel synced from server session state.
|
||||||
// Scope: Presents verified-user controls while reflecting global busy/position/cooldown state.
|
// Scope: Presents verified-user controls while reflecting global busy/position/cooldown state.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function badgeClass(tone) {
|
function badgeClass(tone) {
|
||||||
if (tone === 'good') return 'bg-emerald-600 text-white';
|
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||||
@@ -67,7 +68,17 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`surface relative text-sm text-slate-200 ${wrapClass}`}>
|
<CardFrame
|
||||||
|
title="Lift Controls"
|
||||||
|
|
||||||
|
className={`relative ${wrapClass}`}
|
||||||
|
bodyClassName="text-sm text-slate-200"
|
||||||
|
actions={
|
||||||
|
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
{blocked ? (
|
{blocked ? (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/80 px-1.5 text-center">
|
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/80 px-1.5 text-center">
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.25">
|
||||||
@@ -82,18 +93,10 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
<div className="relative flex items-center justify-center min-h-[1.5rem]">
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
|
||||||
<p className="text-sm text-slate-100">Lift Controls</p>
|
|
||||||
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`absolute right-0 inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="surface-muted px-0.5 py-0.5">
|
<section className="surface-muted px-0.5 py-0.5">
|
||||||
@@ -133,6 +136,6 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
|||||||
|
|
||||||
{lift?.lastError ? <p className="text-xs text-rose-300 text-center">Last error: {lift.lastError}</p> : null}
|
{lift?.lastError ? <p className="text-xs text-rose-300 text-center">Last error: {lift.lastError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
|
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function normalizeState(value) {
|
function normalizeState(value) {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
@@ -98,18 +99,20 @@ export default function VipNeatoCard({
|
|||||||
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
|
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
<CardFrame
|
||||||
|
title="Neato Controls"
|
||||||
|
|
||||||
|
className={wrapClass}
|
||||||
|
bodyClassName="text-sm text-slate-200"
|
||||||
|
actions={
|
||||||
|
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}>
|
||||||
|
{headerStatus}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
<div className="relative flex items-center justify-center min-h-[1.5rem]">
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
|
||||||
<p className="text-sm text-slate-100">Neato Controls</p>
|
|
||||||
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`absolute right-0 inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}
|
|
||||||
>
|
|
||||||
{headerStatus}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
||||||
@@ -217,6 +220,6 @@ export default function VipNeatoCard({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Defines the Vip Private Rover Access Card module and the local helpers/components used in this file.
|
// Purpose: Defines the Vip Private Rover Access Card module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { flowWrapClass, innerFlowClass } from './constants.js';
|
import { flowWrapClass, innerFlowClass } from './constants.js';
|
||||||
|
|
||||||
export default function VipPrivateRoverAccessCard({
|
export default function VipPrivateRoverAccessCard({
|
||||||
@@ -42,9 +43,8 @@ export default function VipPrivateRoverAccessCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
<CardFrame title="Private rover access requests" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
<p className="text-sm text-slate-300">Private rover access requests</p>
|
|
||||||
{requestableRovers.length === 0 ? (
|
{requestableRovers.length === 0 ? (
|
||||||
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
|
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -79,7 +79,6 @@ export default function VipPrivateRoverAccessCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Owns local URL validation UX and writes to the existing profile settings namespace.
|
// Scope: Owns local URL validation UX and writes to the existing profile settings namespace.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
|
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
|
||||||
|
|
||||||
function normalizeHttpUrl(value) {
|
function normalizeHttpUrl(value) {
|
||||||
@@ -54,9 +55,8 @@ export default function VipProfileImageCard({ isVerified = false, fullWidth = fa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
<CardFrame title="Chat profile image URL" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||||
<form className={innerFlowClass} onSubmit={handleSave}>
|
<form className={innerFlowClass} onSubmit={handleSave}>
|
||||||
<p className="text-sm text-slate-300">Chat profile image URL</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Verified users can set a custom avatar for chat and Discord bridge messages.
|
Verified users can set a custom avatar for chat and Discord bridge messages.
|
||||||
</p>
|
</p>
|
||||||
@@ -86,6 +86,6 @@ export default function VipProfileImageCard({ isVerified = false, fullWidth = fa
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
||||||
|
|
||||||
export default function VipVerificationCard({
|
export default function VipVerificationCard({
|
||||||
@@ -61,29 +62,28 @@ export default function VipVerificationCard({
|
|||||||
|
|
||||||
if (pendingRequestId) {
|
if (pendingRequestId) {
|
||||||
return (
|
return (
|
||||||
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
<CardFrame title="Verification" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||||
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestFlowStep === 0) {
|
if (requestFlowStep === 0) {
|
||||||
return (
|
return (
|
||||||
<section className={`surface ${wrapClass}`}>
|
<CardFrame title="Verification" className={wrapClass}>
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
<p className="text-sm text-slate-300">Verification</p>
|
|
||||||
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
|
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
|
||||||
Request Verification
|
Request Verification
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={`surface ${wrapClass}`} onSubmit={handleRequestSubmit}>
|
<CardFrame title="Request verification" className={wrapClass}>
|
||||||
|
<form onSubmit={handleRequestSubmit}>
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
<p className="text-sm text-slate-300">Request verification</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Step {requestFlowStep} of 3
|
Step {requestFlowStep} of 3
|
||||||
</p>
|
</p>
|
||||||
@@ -189,6 +189,7 @@ export default function VipVerificationCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function getSessionSocials(state) {
|
||||||
|
const socials = state?.session?.socials;
|
||||||
|
return Array.isArray(socials) ? socials : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSocialById(state, socialId) {
|
||||||
|
const key = String(socialId || '').trim().toLowerCase();
|
||||||
|
if (!key) return null;
|
||||||
|
return (
|
||||||
|
getSessionSocials(state).find((entry) => {
|
||||||
|
const entryKey = String(entry?.id || entry?.label || '').trim().toLowerCase();
|
||||||
|
return entryKey === key;
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user