mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
barcode game colors!
This commit is contained in:
@@ -16,7 +16,7 @@ llmCommentary:
|
|||||||
overseerControl:
|
overseerControl:
|
||||||
enabled: false
|
enabled: false
|
||||||
# autonomous runs the existing vote-gated loop forever; directAddress only
|
# autonomous runs the existing vote-gated loop forever; directAddress only
|
||||||
# runs one cycle when a chat message starts with the configured name.
|
# runs one cycle when a chat message mentions the configured name.
|
||||||
mode: "autonomous"
|
mode: "autonomous"
|
||||||
observeOnly: true
|
observeOnly: true
|
||||||
postToolsOnlyMessages: false
|
postToolsOnlyMessages: false
|
||||||
|
|||||||
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
@@ -18,8 +18,8 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-4TYOvsyF.js"></script>
|
<script type="module" crossorigin src="/assets/index-C4SKjZW7.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CmnedUzQ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C6VExJdW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ module.exports = {
|
|||||||
id: GAME_ID,
|
id: GAME_ID,
|
||||||
title: 'Most items',
|
title: 'Most items',
|
||||||
description: 'Scan as many different known objects as possible.',
|
description: 'Scan as many different known objects as possible.',
|
||||||
|
themeColor: { r: 16, g: 185, b: 129 },
|
||||||
createInitialState,
|
createInitialState,
|
||||||
normalizeState,
|
normalizeState,
|
||||||
activate,
|
activate,
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ module.exports = {
|
|||||||
id: GAME_ID,
|
id: GAME_ID,
|
||||||
title: 'Scan quest',
|
title: 'Scan quest',
|
||||||
description: 'Scan one or two requested objects in order.',
|
description: 'Scan one or two requested objects in order.',
|
||||||
|
themeColor: { r: 34, g: 211, b: 238 },
|
||||||
createInitialState,
|
createInitialState,
|
||||||
normalizeState,
|
normalizeState,
|
||||||
activate,
|
activate,
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ module.exports = {
|
|||||||
id: GAME_ID,
|
id: GAME_ID,
|
||||||
title: 'Scans per second',
|
title: 'Scans per second',
|
||||||
description: 'Count every scan for five minutes and save the world record.',
|
description: 'Count every scan for five minutes and save the world record.',
|
||||||
|
themeColor: { r: 245, g: 158, b: 11 },
|
||||||
createInitialState,
|
createInitialState,
|
||||||
normalizeState,
|
normalizeState,
|
||||||
activate,
|
activate,
|
||||||
|
|||||||
@@ -195,6 +195,24 @@ function getGameDefinition(gameId) {
|
|||||||
return GAMES_BY_ID[String(gameId || '')] || null;
|
return GAMES_BY_ID[String(gameId || '')] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRgbChannel(value) {
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
return Math.max(0, Math.min(255, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGameThemeColor(definition) {
|
||||||
|
const raw = definition?.themeColor;
|
||||||
|
const r = normalizeRgbChannel(raw?.r);
|
||||||
|
const g = normalizeRgbChannel(raw?.g);
|
||||||
|
const b = normalizeRgbChannel(raw?.b);
|
||||||
|
|
||||||
|
// Games own their visual identity, but the socket payload should always be a
|
||||||
|
// small safe RGB object. Invalid or missing values fall back to neutral text
|
||||||
|
// colors in the browser instead of leaking arbitrary styling data into React.
|
||||||
|
if (r === null || g === null || b === null) return null;
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
function getKnownObjects() {
|
function getKnownObjects() {
|
||||||
try {
|
try {
|
||||||
const snapshot = getRegistrySnapshot();
|
const snapshot = getRegistrySnapshot();
|
||||||
@@ -842,6 +860,7 @@ function buildStatePayload(socket = null) {
|
|||||||
id: game.id,
|
id: game.id,
|
||||||
title: game.title,
|
title: game.title,
|
||||||
description: game.description,
|
description: game.description,
|
||||||
|
themeColor: getGameThemeColor(game),
|
||||||
voteCount: voteCounts[game.id] || 0,
|
voteCount: voteCounts[game.id] || 0,
|
||||||
active: game.id === store.runningGameId,
|
active: game.id === store.runningGameId,
|
||||||
selected: game.id === store.selectedGameId,
|
selected: game.id === store.selectedGameId,
|
||||||
@@ -869,13 +888,16 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
|||||||
if (store.phase === 'running' && runningGame) {
|
if (store.phase === 'running' && runningGame) {
|
||||||
return {
|
return {
|
||||||
...runningGame,
|
...runningGame,
|
||||||
|
themeColor: getGameThemeColor(runningDefinition),
|
||||||
participants,
|
participants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (store.phase === 'results') {
|
if (store.phase === 'results') {
|
||||||
|
const resultDefinition = getGameDefinition(store.resultGameId);
|
||||||
return {
|
return {
|
||||||
id: store.resultGameId,
|
id: store.resultGameId,
|
||||||
title: getGameDefinition(store.resultGameId)?.title || 'Results',
|
title: resultDefinition?.title || 'Results',
|
||||||
|
themeColor: getGameThemeColor(resultDefinition),
|
||||||
status: 'results',
|
status: 'results',
|
||||||
participants,
|
participants,
|
||||||
display: {
|
display: {
|
||||||
@@ -892,6 +914,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
|||||||
return {
|
return {
|
||||||
id: store.selectedGameId,
|
id: store.selectedGameId,
|
||||||
title: selectedDefinition?.title || 'Starting',
|
title: selectedDefinition?.title || 'Starting',
|
||||||
|
themeColor: getGameThemeColor(selectedDefinition),
|
||||||
status: 'starting',
|
status: 'starting',
|
||||||
participants,
|
participants,
|
||||||
display: {
|
display: {
|
||||||
@@ -909,6 +932,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
|||||||
return {
|
return {
|
||||||
id: store.selectedGameId,
|
id: store.selectedGameId,
|
||||||
title: selectedDefinition?.title || 'Join game',
|
title: selectedDefinition?.title || 'Join game',
|
||||||
|
themeColor: getGameThemeColor(selectedDefinition),
|
||||||
status: 'joining',
|
status: 'joining',
|
||||||
participants,
|
participants,
|
||||||
display: {
|
display: {
|
||||||
@@ -926,6 +950,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
|||||||
return {
|
return {
|
||||||
id: store.selectedGameId,
|
id: store.selectedGameId,
|
||||||
title: 'Voting',
|
title: 'Voting',
|
||||||
|
themeColor: getGameThemeColor(selectedDefinition),
|
||||||
status: 'voting',
|
status: 'voting',
|
||||||
participants,
|
participants,
|
||||||
display: {
|
display: {
|
||||||
@@ -944,6 +969,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
|||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
title: 'Barcode games',
|
title: 'Barcode games',
|
||||||
|
themeColor: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
participants,
|
participants,
|
||||||
display: {
|
display: {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
const Fuse = require('fuse.js');
|
|
||||||
const { Ollama } = require('ollama');
|
const { Ollama } = require('ollama');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('overseerControl');
|
const logger = require('../../globals/logger').child('overseerControl');
|
||||||
@@ -36,7 +35,6 @@ const overseerConfig = config.overseerControl || {};
|
|||||||
const RUN_MODE_AUTONOMOUS = 'autonomous';
|
const RUN_MODE_AUTONOMOUS = 'autonomous';
|
||||||
const RUN_MODE_DIRECT_ADDRESS = 'directAddress';
|
const RUN_MODE_DIRECT_ADDRESS = 'directAddress';
|
||||||
const RUN_MODES = new Set([RUN_MODE_AUTONOMOUS, RUN_MODE_DIRECT_ADDRESS]);
|
const RUN_MODES = new Set([RUN_MODE_AUTONOMOUS, RUN_MODE_DIRECT_ADDRESS]);
|
||||||
const DIRECT_ADDRESS_FUZZY_THRESHOLD = 0.3;
|
|
||||||
const enabled = Boolean(overseerConfig.enabled);
|
const enabled = Boolean(overseerConfig.enabled);
|
||||||
const observeOnly = overseerConfig.observeOnly !== false;
|
const observeOnly = overseerConfig.observeOnly !== false;
|
||||||
const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
|
const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
|
||||||
@@ -53,42 +51,18 @@ const runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
|
|||||||
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
||||||
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||||
|
|
||||||
function normalizeDirectAddressText(value) {
|
function normalizeNameMentionText(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
// Direct-address matching is intentionally word-oriented. Replacing
|
// Mention detection is intentionally literal to the configured name. The
|
||||||
// punctuation with spaces lets "overseer," and "overseer:" behave like the
|
// only normalization here is presentation-level cleanup so punctuation and
|
||||||
// same invocation phrase without letting punctuation affect Fuse scores.
|
// repeated whitespace do not decide whether the configured name appears.
|
||||||
.replace(/[^a-z0-9]+/g, ' ')
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, ' ');
|
.replace(/\s+/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const directAddressName = normalizeDirectAddressText(name);
|
const normalizedConfiguredName = normalizeNameMentionText(name);
|
||||||
const directAddressNameWords = directAddressName.split(' ').filter(Boolean);
|
|
||||||
const directAddressAliases = (() => {
|
|
||||||
const aliases = new Set();
|
|
||||||
if (directAddressName) aliases.add(directAddressName);
|
|
||||||
|
|
||||||
// The default name includes a leading article, but users naturally shorten
|
|
||||||
// "The Overseer" to "Overseer". Keeping this as an alias makes the direct
|
|
||||||
// invocation humane while still requiring the match to be at the very start
|
|
||||||
// of the chat message.
|
|
||||||
if (directAddressNameWords[0] === 'the' && directAddressNameWords.length > 1) {
|
|
||||||
aliases.add(directAddressNameWords.slice(1).join(' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(aliases).map((label) => ({
|
|
||||||
label,
|
|
||||||
wordCount: label.split(' ').filter(Boolean).length,
|
|
||||||
}));
|
|
||||||
})();
|
|
||||||
const directAddressFuse = new Fuse(directAddressAliases, {
|
|
||||||
includeScore: true,
|
|
||||||
ignoreLocation: true,
|
|
||||||
threshold: DIRECT_ADDRESS_FUZZY_THRESHOLD,
|
|
||||||
keys: [{ name: 'label', weight: 1 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
timer: null,
|
timer: null,
|
||||||
@@ -324,66 +298,15 @@ function normalizeChatDraft(text) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConfiguredNameAddress(text) {
|
function mentionsConfiguredName(text) {
|
||||||
const clean = String(text || '').trimStart();
|
if (!normalizedConfiguredName) return false;
|
||||||
if (!clean) return false;
|
const normalizedMessage = normalizeNameMentionText(text);
|
||||||
|
if (!normalizedMessage) return false;
|
||||||
|
|
||||||
const lowerText = clean.toLowerCase();
|
// Pad both sides with spaces so a configured name only matches as a whole
|
||||||
const lowerName = name.toLowerCase();
|
// normalized phrase. For example, "The Overseer" matches "hey, The Overseer"
|
||||||
if (!lowerText.startsWith(lowerName)) return false;
|
// but not "The Overseerish".
|
||||||
|
return ` ${normalizedMessage} `.includes(` ${normalizedConfiguredName} `);
|
||||||
const nextChar = clean.charAt(name.length);
|
|
||||||
if (!nextChar) return true;
|
|
||||||
|
|
||||||
// The character after the configured name must separate the invocation from
|
|
||||||
// the rest of the message. This keeps "The Overseer, help" and "the overseer
|
|
||||||
// help" valid while preventing accidental triggers such as "The Overseerish".
|
|
||||||
return /[\s,.:;!?-]/.test(nextChar);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLeadingDirectAddressCandidates(text) {
|
|
||||||
const words = normalizeDirectAddressText(text).split(' ').filter(Boolean);
|
|
||||||
if (!words.length) return [];
|
|
||||||
|
|
||||||
const maxAliasWords = directAddressAliases.reduce((max, alias) => Math.max(max, alias.wordCount), 1);
|
|
||||||
const maxWords = Math.min(words.length, Math.max(2, maxAliasWords + 1));
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
for (let count = 1; count <= maxWords; count += 1) {
|
|
||||||
// Each candidate is made only from the start of the message. This preserves
|
|
||||||
// the "starts with the bot name" contract even though the actual comparison
|
|
||||||
// is fuzzy.
|
|
||||||
candidates.push(words.slice(0, count).join(' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFuzzyConfiguredNameAddress(text) {
|
|
||||||
if (!directAddressAliases.length) return false;
|
|
||||||
|
|
||||||
const candidates = buildLeadingDirectAddressCandidates(text);
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const exactAlias = directAddressAliases.find((alias) => alias.label === candidate);
|
|
||||||
if (exactAlias) return true;
|
|
||||||
|
|
||||||
const best = directAddressFuse.search(candidate)[0];
|
|
||||||
if (!best || Number(best.score) > DIRECT_ADDRESS_FUZZY_THRESHOLD) continue;
|
|
||||||
|
|
||||||
const alias = best.item;
|
|
||||||
const candidateWords = candidate.split(' ').filter(Boolean);
|
|
||||||
if (candidateWords.length !== alias.wordCount) continue;
|
|
||||||
|
|
||||||
// Fuse can rate very short partial phrases surprisingly well. Requiring the
|
|
||||||
// candidate to be close to the alias length keeps first words like "the" or
|
|
||||||
// "over" from waking a multi-word bot while still allowing typos such as
|
|
||||||
// "overseer" -> "oversear" or "the overseer" -> "teh overseer".
|
|
||||||
if (Math.abs(candidate.length - alias.label.length) > 2) continue;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeResult(result) {
|
function summarizeResult(result) {
|
||||||
@@ -850,7 +773,7 @@ subscribe('chat:message', ({ payload } = {}) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!directAddressMode) return;
|
if (!directAddressMode) return;
|
||||||
if (!isConfiguredNameAddress(text) && !isFuzzyConfiguredNameAddress(text)) return;
|
if (!mentionsConfiguredName(text)) return;
|
||||||
void runDirectAddressCycle('direct_address_chat');
|
void runDirectAddressCycle('direct_address_chat');
|
||||||
});
|
});
|
||||||
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
|
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
|
||||||
|
|||||||
@@ -5,6 +5,58 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
|
function clampRgbChannel(value) {
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
return Math.max(0, Math.min(255, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRgb(themeColor) {
|
||||||
|
const r = clampRgbChannel(themeColor?.r);
|
||||||
|
const g = clampRgbChannel(themeColor?.g);
|
||||||
|
const b = clampRgbChannel(themeColor?.b);
|
||||||
|
if (r === null || g === null || b === null) return null;
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGameTheme(themeColor) {
|
||||||
|
const rgb = normalizeRgb(themeColor);
|
||||||
|
if (!rgb) {
|
||||||
|
return {
|
||||||
|
textStyle: undefined,
|
||||||
|
buttonStyle: undefined,
|
||||||
|
titleBoxStyle: undefined,
|
||||||
|
boxStyle: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The game supplies only identity color. This component chooses opacity and
|
||||||
|
// placement so every game stays visually consistent with the dark CardFrame
|
||||||
|
// UI while still being recognizable at a glance.
|
||||||
|
return {
|
||||||
|
textStyle: { color: rgba(rgb, 0.96) },
|
||||||
|
buttonStyle: {
|
||||||
|
borderColor: rgba(rgb, 0.62),
|
||||||
|
borderLeftColor: rgba(rgb, 0.9),
|
||||||
|
backgroundColor: rgba(rgb, 0.1),
|
||||||
|
},
|
||||||
|
selectedButtonStyle: {
|
||||||
|
borderColor: rgba(rgb, 0.86),
|
||||||
|
borderLeftColor: rgba(rgb, 1),
|
||||||
|
backgroundColor: rgba(rgb, 0.16),
|
||||||
|
},
|
||||||
|
titleBoxStyle: {
|
||||||
|
borderLeftColor: rgba(rgb, 0.88),
|
||||||
|
},
|
||||||
|
boxStyle: {
|
||||||
|
borderColor: rgba(rgb, 0.62),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function useClock(enabled) {
|
function useClock(enabled) {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
@@ -28,19 +80,18 @@ function formatTimer(endsAt, now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GameChoice({ game, disabled, onVote }) {
|
function GameChoice({ game, disabled, onVote }) {
|
||||||
|
const theme = getGameTheme(game.themeColor);
|
||||||
|
const style = game.active || game.selected ? theme.selectedButtonStyle || theme.buttonStyle : theme.buttonStyle;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || game.active}
|
disabled={disabled || game.active}
|
||||||
onClick={() => onVote(game.id)}
|
onClick={() => onVote(game.id)}
|
||||||
className={[
|
className="button-dark flex min-h-[4.25rem] flex-col items-start justify-between border-l-4 px-1.5 py-1 text-left disabled:opacity-70"
|
||||||
'button-dark flex min-h-[4.25rem] flex-col items-start justify-between px-1.5 py-1 text-left disabled:opacity-70',
|
style={style}
|
||||||
game.active ? 'border-emerald-500/70' : '',
|
|
||||||
game.selected && !game.active ? 'border-cyan-500/60' : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
>
|
>
|
||||||
<span className="flex w-full items-start justify-between gap-1">
|
<span className="flex w-full items-start justify-between gap-1">
|
||||||
<span className="text-sm font-semibold leading-tight text-neutral-50">{game.title}</span>
|
<span className="text-sm font-semibold leading-tight text-neutral-50" style={theme.textStyle}>{game.title}</span>
|
||||||
<span className="shrink-0 font-mono text-[0.72rem] text-neutral-300">{game.voteCount || 0}</span>
|
<span className="shrink-0 font-mono text-[0.72rem] text-neutral-300">{game.voteCount || 0}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 line-clamp-2 text-xs leading-snug text-neutral-300">{game.description}</span>
|
<span className="mt-0.5 line-clamp-2 text-xs leading-snug text-neutral-300">{game.description}</span>
|
||||||
@@ -51,12 +102,12 @@ function GameChoice({ game, disabled, onVote }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatGrid({ stats }) {
|
function StatGrid({ stats, theme }) {
|
||||||
if (!Array.isArray(stats) || !stats.length) return null;
|
if (!Array.isArray(stats) || !stats.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1 sm:grid-cols-3">
|
<div className="grid gap-1 sm:grid-cols-3">
|
||||||
{stats.slice(0, 6).map((stat) => (
|
{stats.slice(0, 6).map((stat) => (
|
||||||
<div key={stat.label} className="border border-neutral-700 px-1.5 py-1">
|
<div key={stat.label} className="border border-neutral-700 px-1.5 py-1" style={theme.boxStyle}>
|
||||||
<p className="truncate text-[0.68rem] text-neutral-400">{stat.label}</p>
|
<p className="truncate text-[0.68rem] text-neutral-400">{stat.label}</p>
|
||||||
<p className="truncate text-sm font-semibold text-neutral-100">{stat.value}</p>
|
<p className="truncate text-sm font-semibold text-neutral-100">{stat.value}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +116,7 @@ function StatGrid({ stats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParticipantsBlock({ participants }) {
|
function ParticipantsBlock({ participants, theme }) {
|
||||||
const knownParticipants = Array.isArray(participants) ? participants : [];
|
const knownParticipants = Array.isArray(participants) ? participants : [];
|
||||||
// Participants are server-owned because rover scans, identity lookup, and
|
// Participants are server-owned because rover scans, identity lookup, and
|
||||||
// score eligibility all happen outside this panel. The UI only formats the
|
// score eligibility all happen outside this panel. The UI only formats the
|
||||||
@@ -75,7 +126,7 @@ function ParticipantsBlock({ participants }) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 border border-neutral-700 px-2 py-1.5">
|
<div className="min-w-0 border border-neutral-700 px-2 py-1.5" style={theme.boxStyle}>
|
||||||
<p className="text-sm font-semibold text-neutral-400">Participants</p>
|
<p className="text-sm font-semibold text-neutral-400">Participants</p>
|
||||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||||
{knownParticipants.length}
|
{knownParticipants.length}
|
||||||
@@ -151,6 +202,7 @@ export default function BarcodeGamesPanel() {
|
|||||||
const timerText = formatTimer(timerEndsAt, now);
|
const timerText = formatTimer(timerEndsAt, now);
|
||||||
const games = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
const games = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
||||||
const participants = Array.isArray(state.participants) ? state.participants : [];
|
const participants = Array.isArray(state.participants) ? state.participants : [];
|
||||||
|
const activeTheme = getGameTheme(activeGame?.themeColor);
|
||||||
|
|
||||||
const handleVote = async (gameId) => {
|
const handleVote = async (gameId) => {
|
||||||
setPendingGameId(gameId);
|
setPendingGameId(gameId);
|
||||||
@@ -176,8 +228,8 @@ export default function BarcodeGamesPanel() {
|
|||||||
|
|
||||||
<section className="space-y-2 border-t border-neutral-700 pt-2">
|
<section className="space-y-2 border-t border-neutral-700 pt-2">
|
||||||
<div className="grid items-start gap-2 lg:grid-cols-[minmax(0,1fr)_15rem_15rem]">
|
<div className="grid items-start gap-2 lg:grid-cols-[minmax(0,1fr)_15rem_15rem]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 border-l-4 border-neutral-700 pl-2" style={activeTheme.titleBoxStyle}>
|
||||||
<p className="break-words text-2xl font-bold leading-tight text-neutral-50">
|
<p className="break-words text-2xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
|
||||||
{display.title || activeGame?.title || 'No game active'}
|
{display.title || activeGame?.title || 'No game active'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 break-words text-lg font-semibold leading-tight text-neutral-200">
|
<p className="mt-0.5 break-words text-lg font-semibold leading-tight text-neutral-200">
|
||||||
@@ -187,7 +239,7 @@ export default function BarcodeGamesPanel() {
|
|||||||
<p className="mt-1 break-words text-base leading-snug text-neutral-300">{display.secondary}</p>
|
<p className="mt-1 break-words text-base leading-snug text-neutral-300">{display.secondary}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-neutral-700 px-2 py-1.5 text-left lg:text-right">
|
<div className="border border-neutral-700 px-2 py-1.5 text-left lg:text-right" style={activeTheme.boxStyle}>
|
||||||
<p className="text-sm font-semibold text-neutral-400">{display.timer?.label || 'Time'}</p>
|
<p className="text-sm font-semibold text-neutral-400">{display.timer?.label || 'Time'}</p>
|
||||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||||
{timerText || '--'}
|
{timerText || '--'}
|
||||||
@@ -196,9 +248,9 @@ export default function BarcodeGamesPanel() {
|
|||||||
{timerText ? 'active timer' : 'no timer'}
|
{timerText ? 'active timer' : 'no timer'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ParticipantsBlock participants={participants} />
|
<ParticipantsBlock participants={participants} theme={activeTheme} />
|
||||||
</div>
|
</div>
|
||||||
<StatGrid stats={display.stats} />
|
<StatGrid stats={display.stats} theme={activeTheme} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Leaderboard players={state.leaderboard} ownPlayer={state.ownPlayer} />
|
<Leaderboard players={state.leaderboard} ownPlayer={state.ownPlayer} />
|
||||||
|
|||||||
Reference in New Issue
Block a user