overseer improvements and new social buttones

This commit is contained in:
legop3
2026-05-26 23:58:23 -04:00
parent f56d865c8e
commit 742232b882
15 changed files with 253 additions and 297 deletions
+9
View File
@@ -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
+2 -2
View File
@@ -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-EY6LbYPB.js"></script> <script type="module" crossorigin src="/assets/index-3MESEWAw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BIQkdXB4.css"> <link rel="stylesheet" crossorigin href="/assets/index-C8Hua3Ji.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 });
} }
@@ -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>
+1 -13
View File
@@ -32,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());
@@ -91,11 +83,7 @@ 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 */}
@@ -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>
+41 -79
View File
@@ -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 py-0.5 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, 2rem)' }} />
</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>
); );
+15
View File
@@ -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
);
}