mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
fix spectator page, add discord ping filtering
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Bu0ya-aM.js"></script>
|
<script type="module" crossorigin src="/assets/index-B4HXdpZh.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B8zLJlAS.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B5WmdT7o.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ const client = new Client({
|
|||||||
|
|
||||||
const channelCache = new Map();
|
const channelCache = new Map();
|
||||||
|
|
||||||
|
function sanitizeMentions(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return String(text)
|
||||||
|
// strip mention tokens
|
||||||
|
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
|
||||||
|
// neutralize everyone/here
|
||||||
|
.replace(/@everyone/gi, '[everyone]')
|
||||||
|
.replace(/@here/gi, '[here]');
|
||||||
|
}
|
||||||
|
|
||||||
function formatRoverStatus(rover) {
|
function formatRoverStatus(rover) {
|
||||||
if (!rover) return 'Unknown rover';
|
if (!rover) return 'Unknown rover';
|
||||||
const percent = rover.batteryState?.percentDisplay;
|
const percent = rover.batteryState?.percentDisplay;
|
||||||
@@ -80,11 +90,11 @@ async function fetchChannel(id) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendToChannel(id, content, options = {}) {
|
async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }) {
|
||||||
const channel = await fetchChannel(id);
|
const channel = await fetchChannel(id);
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
try {
|
try {
|
||||||
await channel.send({ content, ...options });
|
await channel.send({ content: sanitizeMentions(content), allowedMentions, ...options });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to send Discord message', { id, error: err.message });
|
logger.warn('Failed to send Discord message', { id, error: err.message });
|
||||||
}
|
}
|
||||||
@@ -115,37 +125,61 @@ async function handleStatusCommand(message, roverId) {
|
|||||||
const roster = getRoster();
|
const roster = getRoster();
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
const summary = roster.map((r) => formatRoverStatus(r)).join('\n') || 'No rovers online.';
|
const summary = roster.map((r) => formatRoverStatus(r)).join('\n') || 'No rovers online.';
|
||||||
await message.reply(summary.slice(0, 1900));
|
await message.reply({
|
||||||
|
content: sanitizeMentions(summary.slice(0, 1900)),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rover = findRover(roverId);
|
const rover = findRover(roverId);
|
||||||
await message.reply(formatRoverStatus(rover).slice(0, 1900));
|
await message.reply({
|
||||||
|
content: sanitizeMentions(formatRoverStatus(rover).slice(0, 1900)),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLockCommand(message, roverId, locked) {
|
async function handleLockCommand(message, roverId, locked) {
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
await message.reply('Specify a rover ID. Example: `rover lock alpha`');
|
await message.reply({
|
||||||
|
content: 'Specify a rover ID. Example: `rs lock alpha`',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
lockRover(roverId, locked, { reason: 'discord' });
|
lockRover(roverId, locked, { reason: 'discord' });
|
||||||
await message.reply(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`);
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await message.reply(`Failed: ${err.message}`);
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Failed: ${err.message}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModeCommand(message, mode) {
|
async function handleModeCommand(message, mode) {
|
||||||
const next = String(mode || '').toLowerCase();
|
const next = String(mode || '').toLowerCase();
|
||||||
if (!Object.values(MODES).includes(next)) {
|
if (!Object.values(MODES).includes(next)) {
|
||||||
await message.reply('Invalid mode. Use one of: open, turns, admin, lockdown.');
|
await message.reply({
|
||||||
|
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setMode(next, null, { force: true });
|
setMode(next, null, { force: true });
|
||||||
await message.reply(`Mode set to ${next}.`);
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Mode set to ${next}.`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await message.reply(`Failed to set mode: ${err.message}`);
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Failed to set mode: ${err.message}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +258,8 @@ async function announce({ channelId, content, pingRoleId, color, title, descript
|
|||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
const prefix = pingRoleId ? `<@&${pingRoleId}> ` : '';
|
const prefix = pingRoleId ? `<@&${pingRoleId}> ` : '';
|
||||||
const embed = buildEmbed({ title, description, color });
|
const embed = buildEmbed({ title, description, color });
|
||||||
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: [embed] });
|
const allowedMentions = pingRoleId ? { roles: [pingRoleId], parse: [] } : { parse: [] };
|
||||||
|
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: [embed] }, allowedMentions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBusEvent(event) {
|
function handleBusEvent(event) {
|
||||||
@@ -360,7 +395,7 @@ function handleChatBridgeOutbound(event) {
|
|||||||
if (!bridgeChannelId) return;
|
if (!bridgeChannelId) return;
|
||||||
const line = formatChatLine(payload);
|
const line = formatChatLine(payload);
|
||||||
const text = line.length > 1900 ? `${line.slice(0, 1897)}...` : line;
|
const text = line.length > 1900 ? `${line.slice(0, 1897)}...` : line;
|
||||||
sendToChannel(bridgeChannelId, text);
|
sendToChannel(bridgeChannelId, text, {}, { parse: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
client.on('messageCreate', async (message) => {
|
client.on('messageCreate', async (message) => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function displayName(message) {
|
|||||||
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
|
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel() {
|
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
@@ -42,7 +42,7 @@ export default function ChatPanel() {
|
|||||||
|
|
||||||
async function handleSend(event) {
|
async function handleSend(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canChat) return;
|
if (!canChat || hideInput) return;
|
||||||
const clean = draft.trim();
|
const clean = draft.trim();
|
||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
@@ -58,12 +58,12 @@ export default function ChatPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section flex h-full flex-col space-y-0.5 text-base">
|
||||||
{/* <div className="flex items-center justify-between text-sm text-slate-400">
|
{/* <div className="flex items-center justify-between text-sm text-slate-400">
|
||||||
<span>Chat</span>
|
<span>Chat</span>
|
||||||
<span className="text-xs text-slate-500">{sorted.length}</span>
|
<span className="text-xs text-slate-500">{sorted.length}</span>
|
||||||
</div> */}
|
</div> */}
|
||||||
<div className="surface h-48 overflow-y-auto space-y-0.25" ref={listRef}>
|
<div className="surface flex-1 overflow-y-auto space-y-0.25" ref={listRef}>
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -99,6 +99,7 @@ export default function ChatPanel() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!hideInput && (
|
||||||
<form className="flex gap-0.5" onSubmit={handleSend}>
|
<form className="flex gap-0.5" onSubmit={handleSend}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1"
|
className="field-input flex-1"
|
||||||
@@ -107,13 +108,14 @@ export default function ChatPanel() {
|
|||||||
onFocus={onInputFocus}
|
onFocus={onInputFocus}
|
||||||
onBlur={onInputBlur}
|
onBlur={onInputBlur}
|
||||||
ref={(el) => registerInputRef(el)}
|
ref={(el) => registerInputRef(el)}
|
||||||
placeholder={canChat ? 'Type a message…' : 'Spectators cannot chat'}
|
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
|
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
|
||||||
{sending ? '...' : 'Send'}
|
{sending ? '...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import AuthPanel from './AuthPanel.jsx';
|
import AuthPanel from './AuthPanel.jsx';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
|
import ChatPanel from './ChatPanel.jsx';
|
||||||
|
import NicknameForm from './NicknameForm.jsx';
|
||||||
|
|
||||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||||
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
|
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
|
||||||
@@ -46,6 +48,9 @@ export default function ModeGateOverlay() {
|
|||||||
<div className='w-full justify-center items-center'>
|
<div className='w-full justify-center items-center'>
|
||||||
<DiscordInviteButton text='Join our Discord server for updates!'/>
|
<DiscordInviteButton text='Join our Discord server for updates!'/>
|
||||||
</div>
|
</div>
|
||||||
|
You can use the chat from here though :3
|
||||||
|
<ChatPanel />
|
||||||
|
<NicknameForm />
|
||||||
{/* <p className="text-xs text-slate-500">
|
{/* <p className="text-xs text-slate-500">
|
||||||
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
||||||
changes or after a successful login.
|
changes or after a successful login.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
|
||||||
|
export default function NicknameForm({ compact = false }) {
|
||||||
|
const { session, setNickname } = useSession();
|
||||||
|
const { value, save } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
|
const [nicknameInput, setNicknameInput] = useState(value.nickname || '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const canSetNickname = session?.role !== 'spectator';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNicknameInput(value.nickname || '');
|
||||||
|
}, [value.nickname]);
|
||||||
|
|
||||||
|
async function handleSave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSetNickname) return;
|
||||||
|
const trimmed = (nicknameInput || '').trim().slice(0, 32);
|
||||||
|
if (!trimmed) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await setNickname(trimmed);
|
||||||
|
save({ nickname: trimmed });
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex gap-0.5" onSubmit={handleSave}>
|
||||||
|
<input
|
||||||
|
className="field-input flex-1"
|
||||||
|
value={nicknameInput}
|
||||||
|
onChange={(e) => setNicknameInput(e.target.value)}
|
||||||
|
maxLength={32}
|
||||||
|
placeholder="Enter a nickname"
|
||||||
|
disabled={!canSetNickname}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!canSetNickname || saving} className="button-dark disabled:opacity-50">
|
||||||
|
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,12 @@ function normalizeOrientation(value, fallback) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoomCameraPanel({ defaultOrientation = 'horizontal', orientation: forcedOrientation }) {
|
export default function RoomCameraPanel({
|
||||||
|
defaultOrientation = 'horizontal',
|
||||||
|
orientation: forcedOrientation,
|
||||||
|
hideLayoutToggle = false,
|
||||||
|
hideHeader = false,
|
||||||
|
}) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const cameras = session?.roomCameras || [];
|
const cameras = session?.roomCameras || [];
|
||||||
const sourceDescriptors = cameras.map((camera) => ({ type: 'room', id: camera.id, key: `room:${camera.id}` }));
|
const sourceDescriptors = cameras.map((camera) => ({ type: 'room', id: camera.id, key: `room:${camera.id}` }));
|
||||||
@@ -34,7 +39,7 @@ export default function RoomCameraPanel({ defaultOrientation = 'horizontal', ori
|
|||||||
: orientation;
|
: orientation;
|
||||||
const containerClass =
|
const containerClass =
|
||||||
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
||||||
const showLayoutToggle = !forcedOrientation && cameras.length > 0;
|
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
|
||||||
|
|
||||||
if (cameras.length === 0) {
|
if (cameras.length === 0) {
|
||||||
return <EmptyState />;
|
return <EmptyState />;
|
||||||
@@ -42,6 +47,7 @@ export default function RoomCameraPanel({ defaultOrientation = 'horizontal', ori
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section space-y-0.5 text-base">
|
||||||
|
{!hideHeader && (
|
||||||
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<p>Room cameras</p>
|
<p>Room cameras</p>
|
||||||
@@ -65,6 +71,7 @@ export default function RoomCameraPanel({ defaultOrientation = 'horizontal', ori
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
const key = `room:${camera.id}`;
|
const key = `room:${camera.id}`;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
|||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { useSocket } from '../context/SocketContext.jsx';
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
|
import NicknameForm from './NicknameForm.jsx';
|
||||||
|
|
||||||
function roleColors(role) {
|
function roleColors(role) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@@ -25,11 +26,9 @@ function formatLabel(user, selfId) {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserListPanel() {
|
export default function UserListPanel({ hideNicknameForm = false, hideHeader = false }) {
|
||||||
const { session, setNickname } = useSession();
|
const { session, setNickname } = useSession();
|
||||||
const { value, save } = useSettingsNamespace('profile', { nickname: '' });
|
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
const [nicknameInput, setNicknameInput] = useState(value.nickname || '');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const lastSyncedSocketRef = useRef(null);
|
const lastSyncedSocketRef = useRef(null);
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const canSetNickname = session?.role !== 'spectator';
|
const canSetNickname = session?.role !== 'spectator';
|
||||||
@@ -39,20 +38,17 @@ export default function UserListPanel() {
|
|||||||
const turnQueues = session?.turnQueues || {};
|
const turnQueues = session?.turnQueues || {};
|
||||||
const roster = session?.roster || [];
|
const roster = session?.roster || [];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNicknameInput(value.nickname || '');
|
|
||||||
}, [value.nickname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canSetNickname) return;
|
if (!canSetNickname) return;
|
||||||
if (!session?.socketId) return;
|
if (!session?.socketId) return;
|
||||||
|
const nicknameInput = value.nickname || '';
|
||||||
if (!nicknameInput) return;
|
if (!nicknameInput) return;
|
||||||
if (session.socketId === lastSyncedSocketRef.current) return;
|
if (session.socketId === lastSyncedSocketRef.current) return;
|
||||||
const currentId = session.socketId;
|
const currentId = session.socketId;
|
||||||
setNickname(nicknameInput).then(() => {
|
setNickname(nicknameInput).then(() => {
|
||||||
lastSyncedSocketRef.current = currentId;
|
lastSyncedSocketRef.current = currentId;
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [canSetNickname, nicknameInput, session?.socketId, setNickname]);
|
}, [canSetNickname, session?.socketId, setNickname, value.nickname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return undefined;
|
if (!socket) return undefined;
|
||||||
@@ -95,50 +91,25 @@ export default function UserListPanel() {
|
|||||||
return Math.ceil(ms / 1000);
|
return Math.ceil(ms / 1000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleSave(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!canSetNickname) return;
|
|
||||||
const trimmed = (nicknameInput || '').trim().slice(0, 32);
|
|
||||||
if (!trimmed) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await setNickname(trimmed);
|
|
||||||
save({ nickname: trimmed });
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section flex h-full flex-col space-y-0.5 text-base">
|
||||||
|
{!hideNicknameForm && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{/* <p className="text-sm text-slate-400">Nickname</p> */}
|
<NicknameForm />
|
||||||
<form className="flex gap-0.5" onSubmit={handleSave}>
|
|
||||||
<input
|
|
||||||
className="field-input flex-1"
|
|
||||||
value={nicknameInput}
|
|
||||||
onChange={(e) => setNicknameInput(e.target.value)}
|
|
||||||
maxLength={32}
|
|
||||||
placeholder="Enter a nickname"
|
|
||||||
disabled={!canSetNickname}
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={!canSetNickname || saving} className="button-dark disabled:opacity-50">
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
|
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
{!hideHeader && (
|
||||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||||
<span>{isTurnsMode ? 'Turn queues' : 'Users'}</span>
|
<span>{isTurnsMode ? 'Turn queues' : 'Users'}</span>
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
{isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
{isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface h-48 overflow-y-auto space-y-0.25">
|
)}
|
||||||
|
<div className="surface flex-1 min-h-[12rem] overflow-y-auto space-y-0.25">
|
||||||
{isTurnsMode ? (
|
{isTurnsMode ? (
|
||||||
Object.keys(turnQueues || {}).length === 0 ? (
|
Object.keys(turnQueues || {}).length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||||
|
|||||||
@@ -87,9 +87,13 @@ function RoverRow({ roster, frames, videoSources, session }) {
|
|||||||
function SecondaryRow() {
|
function SecondaryRow() {
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-1 gap-0.5 lg:grid-cols-[2fr_1fr_1fr]">
|
<section className="grid grid-cols-1 gap-0.5 lg:grid-cols-[2fr_1fr_1fr]">
|
||||||
<RoomCameraPanel defaultOrientation="horizontal" />
|
<RoomCameraPanel defaultOrientation="horizontal" hideLayoutToggle hideHeader />
|
||||||
<UserListPanel />
|
<div className="flex flex-col">
|
||||||
<ChatPanel />
|
<UserListPanel hideNicknameForm hideHeader />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ChatPanel hideInput hideSpectatorNotice />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user