This commit is contained in:
legop3
2026-04-18 22:55:16 -04:00
parent cd70258a21
commit f4182073ee
8 changed files with 103 additions and 36 deletions
+57 -8
View File
@@ -1,9 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import ChatMessageRow from './ChatMessageRow.jsx';
import ChatTypingRow from './ChatTypingRow.jsx';
import ButtonBoxTile from './ButtonBoxTile.jsx';
const LIFETIME_MS = 3000;
const BUTTONBOX_LIFETIME_MS = 3500;
const DEFAULT_COLOR = '#2196f3';
function buildKey(alert) {
@@ -13,9 +16,37 @@ function buildKey(alert) {
}
export default function AlertFeed({ scale = 1 }) {
const { alerts } = useSession();
const { alerts, session, pushAlert } = useSession();
const socket = useSocket();
const [now, setNow] = useState(() => Date.now());
const latest = useMemo(() => alerts.slice(-3).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
useEffect(() => {
function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
const buttons = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
const goal = Number.isFinite(button.goal) ? button.goal : 0;
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
const rewardName =
typeof button.rewardName === 'string' && button.rewardName.trim()
? button.rewardName.trim()
: 'Unassigned';
pushAlert({
id: `buttonbox-active-${buttonId}`,
kind: 'buttonbox-active',
lifetimeMs: BUTTONBOX_LIFETIME_MS,
payload: { buttonId, count, goal, rewardNumber, rewardName },
});
}
socket.on('buttonBox:increment', onButtonIncrement);
return () => {
socket.off('buttonBox:increment', onButtonIncrement);
};
}, [pushAlert, session?.buttonBox?.buttons, socket]);
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
useEffect(() => {
if (!latest.length) return undefined;
@@ -23,12 +54,15 @@ export default function AlertFeed({ scale = 1 }) {
return () => clearInterval(interval);
}, [latest.length]);
const visible = latest
.map((item) => ({
...item,
age: now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0),
}))
.filter((item) => item.age <= LIFETIME_MS);
const visibleByKey = new Map();
latest.forEach((item) => {
const age = now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0);
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
if (age <= lifetimeMs) {
visibleByKey.set(item.key, { ...item, age });
}
});
const visible = Array.from(visibleByKey.values()).slice(-3);
if (!visible.length) return null;
@@ -66,6 +100,21 @@ function hexToRgb(hex) {
}
function AlertToast({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
return (
<div className="pointer-events-none w-[12.5rem]">
<ButtonBoxTile
buttonId={payload.buttonId}
count={payload.count}
goal={payload.goal}
rewardNumber={payload.rewardNumber}
rewardName={payload.rewardName}
className="bg-cyan-900/45"
/>
</div>
);
}
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
+9 -13
View File
@@ -3,6 +3,7 @@ import { useSession } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import ButtonBoxTile from './ButtonBoxTile.jsx';
const FLASH_MS = 420;
const REWARD_FLASH_MS = 1200;
@@ -139,20 +140,15 @@ export default function ButtonBoxPanel() {
const rewardActive = Boolean(rewardFlash[id]);
return (
<article
<ButtonBoxTile
key={id}
className={[
'surface px-0.5 py-0.5 text-center',
incActive ? 'bg-cyan-900/45' : '',
rewardActive ? 'bg-fuchsia-900/45' : '',
]
.filter(Boolean)
.join(' ')}
>
<p className="text-xs text-slate-400">Button {id}</p>
<p className="text-sm font-semibold text-white">{count} / {goal}</p>
<p className="truncate text-[0.7rem] text-slate-300">#{rewardNumber} {rewardName}</p>
</article>
buttonId={id}
count={count}
goal={goal}
rewardNumber={rewardNumber}
rewardName={rewardName}
className={[incActive ? 'bg-cyan-900/45' : '', rewardActive ? 'bg-fuchsia-900/45' : ''].filter(Boolean).join(' ')}
/>
);
})}
</div>
+16
View File
@@ -0,0 +1,16 @@
export default function ButtonBoxTile({
buttonId,
count,
goal,
rewardNumber,
rewardName,
className = '',
}) {
return (
<article className={['surface px-0.5 py-0.5 text-center', className].filter(Boolean).join(' ')}>
<p className="text-xs text-slate-400">Button {buttonId}</p>
<p className="text-sm font-semibold text-white">{count} / {goal}</p>
<p className="truncate text-[0.7rem] text-slate-300">#{rewardNumber} {rewardName}</p>
</article>
);
}
+11 -5
View File
@@ -177,8 +177,10 @@ function SpectatorContent() {
{isPortraitLayout ? (
<div className={`${topBarItemClass} ${portraitItemHeight} flex flex-col gap-0.5`}>
<CommunityGoalBanner layout="desktop" dismissable={false} className="text-sm" />
<div className="min-h-0 flex-1">
<RawUserPilePanel hideNicknameForm hideHeader compact fillHeight className="h-full" />
<div className="min-h-0 overflow-y-auto">
<div className="panel h-full">
<RoverQueuesPanel title="Rovers" />
</div>
</div>
</div>
) : (
@@ -187,9 +189,13 @@ function SpectatorContent() {
</div>
)}
<div className={`${topBarItemClass} ${isPortraitLayout ? `${portraitItemHeight} overflow-y-auto` : ''}`}>
<div className="panel h-full">
<RoverQueuesPanel title="Rovers" />
</div>
{isPortraitLayout ? (
<RawUserPilePanel hideNicknameForm hideHeader compact fillHeight className="h-full" />
) : (
<div className="panel h-full">
<RoverQueuesPanel title="Rovers" />
</div>
)}
</div>
{!isPortraitLayout ? (
<div className={`${topBarItemClass} flex-1 min-h-0`}>