mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
overseer improvements and new social buttones
This commit is contained in:
@@ -24,6 +24,7 @@ overseerControl:
|
||||
profileImageUrl: "https://example.com/overseer.png"
|
||||
gateIntervalMs: 2000
|
||||
heartbeatMs: 30000
|
||||
postChatDelayMs: 20000
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
@@ -119,12 +120,20 @@ socials:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
- id: "wiki"
|
||||
label: "Wiki"
|
||||
url: "https://wiki.example.com"
|
||||
icon: "FaBook"
|
||||
color: "#475569"
|
||||
- id: "throne"
|
||||
label: "Throne"
|
||||
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-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-EY6LbYPB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BIQkdXB4.css">
|
||||
<script type="module" crossorigin src="/assets/index-3MESEWAw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C8Hua3Ji.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,6 +4,7 @@ const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_
|
||||
const DEFAULT_NAME = 'The Overseer';
|
||||
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
||||
const DEFAULT_HEARTBEAT_MS = 30000;
|
||||
const DEFAULT_POST_CHAT_DELAY_MS = 20000;
|
||||
const MIN_INTERVAL_MS = 250;
|
||||
const MAX_RUN_HISTORY = 100;
|
||||
const MAX_CHAT_CONTEXT = 12;
|
||||
@@ -19,6 +20,7 @@ module.exports = {
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_GATE_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_MS,
|
||||
DEFAULT_POST_CHAT_DELAY_MS,
|
||||
MAX_RUN_HISTORY,
|
||||
MAX_CHAT_CONTEXT,
|
||||
MAX_BOT_CONTEXT,
|
||||
|
||||
@@ -60,16 +60,28 @@ function buildConversation({ recentMessages, name }) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
function buildModelMessages({ systemPrompt, stateUpdate, memorySummary, conversationMessages, availableTools, blockedTools }) {
|
||||
function buildModelMessages({
|
||||
systemPrompt,
|
||||
stateUpdate,
|
||||
memorySummary,
|
||||
recentEvents,
|
||||
conversationMessages,
|
||||
availableTools,
|
||||
blockedTools,
|
||||
}) {
|
||||
const messages = [];
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
const metadataSections = [];
|
||||
metadataSections.push(`STATE_UPDATE\n${stateUpdate}`);
|
||||
if (memorySummary) metadataSections.push(`MEMORY_UPDATE\n${memorySummary}`);
|
||||
metadataSections.push(
|
||||
`tool_constraints:\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
||||
);
|
||||
messages.push({ role: 'user', content: metadataSections.join('\n\n') });
|
||||
messages.push({ role: 'system', content: `ROOM_SNAPSHOT\n${stateUpdate}` });
|
||||
if (memorySummary) {
|
||||
messages.push({ role: 'system', content: `MEMORY_SUMMARY\n${memorySummary}` });
|
||||
}
|
||||
if (recentEvents) {
|
||||
messages.push({ role: 'system', content: `RECENT_EVENTS\n${recentEvents}` });
|
||||
}
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `TOOL_CONSTRAINTS\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
||||
});
|
||||
(conversationMessages || []).forEach((message) => {
|
||||
if (!message || !message.role || !message.content) return;
|
||||
messages.push(message);
|
||||
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_GATE_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_MS,
|
||||
DEFAULT_POST_CHAT_DELAY_MS,
|
||||
MAX_RUN_HISTORY,
|
||||
MAX_CHAT_CONTEXT,
|
||||
MAX_BOT_CONTEXT,
|
||||
@@ -40,6 +41,7 @@ const model = String(overseerConfig.model || '').trim();
|
||||
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_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 postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
||||
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
||||
@@ -67,6 +69,7 @@ let status = {
|
||||
promptPath: PROMPT_PATH,
|
||||
gateIntervalMs,
|
||||
heartbeatMs,
|
||||
postChatDelayMs,
|
||||
alwaysRunModel,
|
||||
postToolsOnlyMessages,
|
||||
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) {
|
||||
const runId = runtime.tickCount;
|
||||
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||
@@ -317,6 +334,7 @@ async function runDecision(triggerReason) {
|
||||
systemPrompt,
|
||||
stateUpdate,
|
||||
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||
recentEvents: buildRecentEventsSummary(),
|
||||
conversationMessages,
|
||||
availableTools: toolState.available,
|
||||
blockedTools: toolState.blocked,
|
||||
@@ -360,6 +378,7 @@ async function runDecision(triggerReason) {
|
||||
|
||||
const actionResults = [];
|
||||
const requestedActions = toolCalls;
|
||||
let postedChat = false;
|
||||
let outcome = observeOnly ? 'observed' : 'executed';
|
||||
const reason = observeOnly ? 'observe-only mode' : null;
|
||||
|
||||
@@ -399,11 +418,13 @@ async function runDecision(triggerReason) {
|
||||
if (toolCallFeed.length > 0) {
|
||||
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||
postedChat = true;
|
||||
} else if (postToolsOnlyMessages) {
|
||||
sendSystemMessage('', { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||
}
|
||||
} else if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
|
||||
postedChat = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +456,8 @@ async function runDecision(triggerReason) {
|
||||
generationMs,
|
||||
blockedTools: toolState.blocked,
|
||||
});
|
||||
|
||||
return { postedChat };
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
@@ -442,12 +465,16 @@ async function tick() {
|
||||
runtime.inFlight = true;
|
||||
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
|
||||
|
||||
let nextDelayMs = gateIntervalMs;
|
||||
try {
|
||||
const triggerReason = computeTriggerReason();
|
||||
if (!triggerReason) {
|
||||
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
|
||||
} else {
|
||||
await runDecision(triggerReason);
|
||||
const runResult = await runDecision(triggerReason);
|
||||
if (runResult?.postedChat) {
|
||||
nextDelayMs = postChatDelayMs;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const failure = buildFailureInfo(err);
|
||||
@@ -462,8 +489,8 @@ async function tick() {
|
||||
} finally {
|
||||
runtime.inFlight = false;
|
||||
if (status.running) {
|
||||
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs });
|
||||
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
|
||||
runtime.timer = setTimeout(tick, nextDelayMs);
|
||||
} else {
|
||||
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import SocialButton from '../../SocialButton/index.jsx';
|
||||
function TurnsOverlay({
|
||||
roverId = null,
|
||||
mobileHud = false,
|
||||
discordUrl: discordUrlProp = null,
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
@@ -16,19 +15,9 @@ function TurnsOverlay({
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
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 {
|
||||
state: { lastControlIntentAt },
|
||||
} = useControlSystem();
|
||||
const effectiveDiscordUrl = discordUrlProp || discordUrl;
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||
@@ -214,11 +203,7 @@ function TurnsOverlay({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-auto mt-0.5">
|
||||
<SocialButton
|
||||
id="discord"
|
||||
label="Join our Discord while you wait!"
|
||||
url={effectiveDiscordUrl}
|
||||
/>
|
||||
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,14 +32,6 @@ export default function ModeGateOverlay() {
|
||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
||||
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
|
||||
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 privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
@@ -91,11 +83,7 @@ export default function ModeGateOverlay() {
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<div className="w-full justify-center items-center">
|
||||
<SocialButton
|
||||
id="discord"
|
||||
label="Join our Discord server for updates!"
|
||||
url={discordUrl}
|
||||
/>
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!"/>
|
||||
</div>
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
function ControlRow({ label, keyLabel }) {
|
||||
return (
|
||||
@@ -56,15 +55,6 @@ export default function QuickstartOverlay({
|
||||
}) {
|
||||
const { state } = useControlSystem();
|
||||
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]);
|
||||
|
||||
if (!visible) return null;
|
||||
@@ -98,7 +88,7 @@ export default function QuickstartOverlay({
|
||||
<div className="surface p-0.5">
|
||||
<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>
|
||||
<SocialButton id="discord" label="Join Discord" url={discordUrl} />
|
||||
<SocialButton id="discord" label="Join Discord" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,97 +1,59 @@
|
||||
// Social Button
|
||||
// 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.
|
||||
import { useEffect } from 'react';
|
||||
import { FaBook, FaCoffee, FaCrown, FaDiscord, FaLink } from 'react-icons/fa';
|
||||
import * as FaIcons 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 = {
|
||||
discord: FaDiscord,
|
||||
kofi: FaCoffee,
|
||||
'ko-fi': FaCoffee,
|
||||
wiki: FaBook,
|
||||
throne: FaCrown,
|
||||
};
|
||||
|
||||
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 sanitizeCssColor(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||
if (/^(rgb|rgba|hsl|hsla)\([^)]+\)$/.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeId(id, label) {
|
||||
if (typeof id === 'string' && id.trim()) return id.trim().toLowerCase();
|
||||
if (typeof label === 'string' && label.trim()) {
|
||||
return label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
return '';
|
||||
function resolveIcon(iconName) {
|
||||
if (typeof iconName !== 'string' || !iconName.trim()) return FaLink;
|
||||
const icon = FaIcons[iconName.trim()];
|
||||
return typeof icon === 'function' ? icon : FaLink;
|
||||
}
|
||||
|
||||
export default function SocialButton({ id, label, url, className = '' }) {
|
||||
if (!url) return null;
|
||||
useSocialButtonStyles();
|
||||
const key = normalizeId(id, label);
|
||||
const Icon = ICONS_BY_ID[key] || FaLink;
|
||||
const gradientStyle = GRADIENT_STYLE_BY_ID[key] || null;
|
||||
const text = label || id || 'Link';
|
||||
export default function SocialButton({ id = null, label, url, icon, color, layout = 'stacked', className = '' }) {
|
||||
const socialFromId = useSessionSelector((state) => (id ? getSocialById(state, id) : null));
|
||||
const resolvedUrl = url || socialFromId?.url || null;
|
||||
if (!resolvedUrl) return null;
|
||||
const resolvedIcon = icon || socialFromId?.icon || null;
|
||||
const resolvedColor = color || socialFromId?.color || null;
|
||||
const Icon = resolveIcon(resolvedIcon);
|
||||
const bgColor = sanitizeCssColor(resolvedColor);
|
||||
const text = label || socialFromId?.label || 'Link';
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
href={resolvedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 ${
|
||||
gradientStyle ? '' : 'bg-slate-700 hover:bg-slate-600'
|
||||
} ${className}`}
|
||||
style={gradientStyle || undefined}
|
||||
className={`grid h-full min-h-0 w-full place-items-center rounded-md bg-slate-700 px-0.5 py-0.5 text-center text-sm font-medium text-white transition hover:opacity-90 ${className}`}
|
||||
style={bgColor ? { backgroundColor: bgColor } : undefined}
|
||||
>
|
||||
<Icon className="mr-0" />
|
||||
{text}
|
||||
{isInline ? (
|
||||
<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, 2rem)' }} />
|
||||
</span>
|
||||
<span className="w-full break-words leading-tight">{text}</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,46 +4,18 @@
|
||||
import { useSessionSelector } from '../../context/SessionContext.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 = '' }) {
|
||||
const socialsInput = useSessionSelector((state) => ({
|
||||
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);
|
||||
const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4);
|
||||
|
||||
if (!socials.length) return null;
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -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