live updates and file organization

This commit is contained in:
2025-10-06 14:47:05 -04:00
parent ca23c0ab8c
commit 6a78ec6453
12 changed files with 419 additions and 152 deletions

View File

@@ -273,7 +273,8 @@ console.log('Postgres enabled for persistence');
// Simple Server-Sent Events (SSE) broadcaster
const sseClients = new Map(); // key: guildId or '*' -> array of res
function publishEvent(guildId, type, payload) {
const msg = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
const enriched = Object.assign({}, payload || {}, { guildId });
const msg = `event: ${type}\ndata: ${JSON.stringify(enriched)}\n\n`;
// send to guild-specific subscribers
const list = sseClients.get(guildId) || [];
for (const res of list.slice()) {
@@ -303,6 +304,31 @@ app.get('/api/events', (req, res) => {
});
});
// Health endpoint used by frontend to detect backend availability
app.get('/api/servers/health', async (req, res) => {
try {
// Basic checks: server is running; optionally check DB connectivity
const health = { ok: true, db: null, bot: null };
try {
// if pgClient is available, attempt a simple query
if (pgClient && typeof pgClient.query === 'function') {
await pgClient.query('SELECT 1');
health.db = true;
}
} catch (e) {
health.db = false;
}
try {
health.bot = (bot && bot.client && bot.client.user) ? true : false;
} catch (e) {
health.bot = false;
}
res.json(health);
} catch (e) {
res.status(500).json({ ok: false });
}
});
app.get('/api/servers/:guildId/settings', async (req, res) => {
const { guildId } = req.params;
try {
@@ -612,6 +638,21 @@ app.get('/', (req, res) => {
res.send('Hello from the backend!');
});
// Debug helper: publish an arbitrary SSE event for a guild (guarded by DEBUG_SSE env var)
app.post('/api/servers/:guildId/debug/publish', express.json(), (req, res) => {
if (!process.env.DEBUG_SSE || process.env.DEBUG_SSE === '0') return res.status(404).json({ success: false, message: 'Not found' });
try {
const { guildId } = req.params;
const { type, payload } = req.body || {};
if (!type) return res.status(400).json({ success: false, message: 'Missing event type' });
publishEvent(guildId, type, payload || {});
return res.json({ success: true });
} catch (e) {
console.error('Debug publish failed:', e);
return res.status(500).json({ success: false });
}
});
// Return list of bot commands and per-guild enabled/disabled status
app.get('/api/servers/:guildId/commands', async (req, res) => {
try {

View File

@@ -0,0 +1,12 @@
const { Pool } = require('pg');
(async function(){
try{
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const res = await pool.query('SELECT guild_id FROM servers LIMIT 10');
console.log('servers:', JSON.stringify(res.rows, null, 2));
await pool.end();
}catch(err){
console.error('ERR', err && err.message ? err.message : err);
process.exit(1);
}
})();

View File

@@ -61,4 +61,17 @@
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard')
- [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar
- [x] Maintenance page
- Frontend displays a maintenance page with a loading indicator when the backend is offline; it polls the backend and reloads UI immediately when the backend is available.
- [x] Global backend health & SSE
- [x] Added `BackendContext` to centralize health polling and a single shared EventSource
- [x] Pages (including `ServerSettings`) use the shared event bus for live updates so the whole site receives changes in real-time
- [ ] Frontend file re-organization
- [ ] Verify guild-scoped SSE payloads include guildId and frontend filters events by guild (in-progress)
- [ ] Add debug SSE publish endpoint to help validate real-time flows (done, guarded by DEBUG_SSE)
- [x] Created `frontend/src/lib/api.js` and refactored some modules to use it
- [x] Created `frontend/src/components/common` and `frontend/src/components/server`
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
- [ ] Remove legacy top-level duplicate files (archival recommended)

View File

@@ -1,36 +1,62 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { UserProvider } from './contexts/UserContext';
import { BackendProvider, useBackend } from './contexts/BackendContext';
import { CssBaseline } from '@mui/material';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import ServerSettings from './components/ServerSettings';
import ServerSettings from './components/server/ServerSettings';
import NavBar from './components/NavBar';
import HelpPage from './components/HelpPage';
import HelpPage from './components/server/HelpPage';
import DiscordPage from './components/DiscordPage';
import MaintenancePage from './components/common/MaintenancePage';
function AppInner() {
const { backendOnline, checking, forceCheck } = useBackend();
const handleRetry = async () => {
await forceCheck();
};
function App() {
return (
<UserProvider>
<ThemeProvider>
<CssBaseline />
<Router>
<TitleSetter />
<NavBar />
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
{!backendOnline ? (
<MaintenancePage onRetry={handleRetry} checking={checking} />
) : (
<>
<NavBar />
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
</>
)}
</Router>
</ThemeProvider>
</UserProvider>
);
}
function App() {
return (
<UserProvider>
<ThemeProvider>
<BackendProvider>
<AppInner />
</BackendProvider>
</ThemeProvider>
</UserProvider>
);
}
export default App;
// small helper component to set the browser tab title based on current route

View File

@@ -5,9 +5,9 @@ import MoreVertIcon from '@mui/icons-material/MoreVert';
import { UserContext } from '../contexts/UserContext';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
import axios from 'axios';
import { get, post } from '../lib/api';
import ConfirmDialog from './ConfirmDialog';
import ConfirmDialog from './common/ConfirmDialog';
const Dashboard = () => {
const navigate = useNavigate();
@@ -78,7 +78,7 @@ const Dashboard = () => {
const statuses = {};
await Promise.all(guilds.map(async (g) => {
try {
const resp = await axios.get(`${API_BASE}/api/servers/${g.id}/bot-status`);
const resp = await get(`/api/servers/${g.id}/bot-status`);
statuses[g.id] = resp.data.isBotInServer;
} catch (err) {
statuses[g.id] = false;
@@ -99,7 +99,7 @@ const Dashboard = () => {
const handleInviteBot = (e, guild) => {
e.stopPropagation();
axios.get(`${API_BASE}/api/client-id`).then(resp => {
get('/api/client-id').then(resp => {
const clientId = resp.data.clientId;
if (!clientId) {
setSnackbarMessage('No client ID available');
@@ -124,7 +124,7 @@ const Dashboard = () => {
const handleConfirmLeave = async () => {
if (!selectedGuild) return;
try {
await axios.post(`${API_BASE}/api/servers/${selectedGuild.id}/leave`);
await post(`/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
setSnackbarMessage('Bot left the server');
setSnackbarOpen(true);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Box, Typography, Button, CircularProgress } from '@mui/material';
const MaintenancePage = ({ onRetry, checking }) => {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'background.default', p: 3 }}>
<Box sx={{ maxWidth: 640, textAlign: 'center' }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>EhChadServices is currently under maintenance</Typography>
<Typography sx={{ mb: 2 }}>We're checking the service status and will reload automatically when the backend is available. Please check back in a few moments.</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 2, mt: 2 }}>
<CircularProgress size={40} />
<Typography>{checking ? 'Checking...' : 'Idle'}</Typography>
</Box>
<Box sx={{ mt: 3 }}>
<Button variant="contained" onClick={onRetry}>Retry now</Button>
</Box>
</Box>
</Box>
);
};
export default MaintenancePage;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { get } from '../../lib/api';
import { Box, IconButton, Typography } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -10,9 +10,7 @@ const HelpPage = () => {
const [commands, setCommands] = useState([]);
useEffect(() => {
const API_BASE = process.env.REACT_APP_API_BASE || '';
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(res => {
get(`/api/servers/${guildId}/commands`).then(res => {
const cmds = res.data || [];
// sort: locked commands first (preserve relative order), then others alphabetically
const locked = cmds.filter(c => c.locked);

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useBackend } from '../../contexts/BackendContext';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import axios from 'axios';
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Snackbar, Alert } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
import ConfirmDialog from './ConfirmDialog';
import ConfirmDialog from '../common/ConfirmDialog';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
@@ -80,51 +81,48 @@ const ServerSettings = () => {
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
// Check if bot is in server
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`)
.then(response => {
setIsBotInServer(response.data.isBotInServer);
});
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`).then(response => {
setIsBotInServer(response.data.isBotInServer);
}).catch(() => {
// Backend is likely down. Don't spam console with network errors — show friendly UI instead.
setIsBotInServer(false);
});
// Fetch client ID
axios.get(`${API_BASE}/api/client-id`)
.then(response => {
setClientId(response.data.clientId);
});
axios.get(`${API_BASE}/api/client-id`).then(response => {
setClientId(response.data.clientId);
}).catch(() => {
// ignore when offline
});
// Fetch channels
axios.get(`${API_BASE}/api/servers/${guildId}/channels`)
.then(response => {
setChannels(response.data);
});
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
setChannels(response.data);
}).catch(() => {
setChannels([]);
});
// Fetch welcome/leave settings
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`)
.then(response => {
if (response.data) {
setWelcomeLeaveSettings(response.data);
}
});
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
if (response.data) setWelcomeLeaveSettings(response.data);
}).catch(() => {
// ignore
});
// Fetch roles
axios.get(`${API_BASE}/api/servers/${guildId}/roles`)
.then(response => {
setRoles(response.data);
});
axios.get(`${API_BASE}/api/servers/${guildId}/roles`).then(response => {
setRoles(response.data);
}).catch(() => setRoles([]));
// Fetch autorole settings
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`)
.then(response => {
if (response.data) {
setAutoroleSettings(response.data);
}
});
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`).then(response => {
if (response.data) setAutoroleSettings(response.data);
}).catch(() => {});
// Fetch commands/help list
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(response => {
setCommandsList(response.data || []);
})
.catch(() => setCommandsList([]));
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(response => {
setCommandsList(response.data || []);
}).catch(() => setCommandsList([]));
// Fetch invites
// Fetch live notifications settings and watched users
@@ -134,10 +132,10 @@ const ServerSettings = () => {
setLiveChannelId(s.channelId || '');
setLiveTwitchUser(s.twitchUser || '');
}).catch(() => {});
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
axios.get(`${API_BASE}/api/servers/${guildId}/invites`)
.then(resp => setInvites(resp.data || []))
.catch(() => setInvites([]));
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
// Open commands accordion if navigated from Help back button
if (location.state && location.state.openCommands) {
@@ -146,97 +144,136 @@ const ServerSettings = () => {
}, [guildId, location.state]);
// Subscribe to backend Server-Sent Events for real-time updates
// Listen to backend events for live notifications and twitch user updates
const { eventTarget } = useBackend();
useEffect(() => {
if (!guildId) return;
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
const url = `${API_BASE}/api/events?guildId=${encodeURIComponent(guildId)}`;
let es = null;
try {
es = new EventSource(url);
} catch (err) {
console.warn('EventSource not available or failed to connect', err);
return;
}
es.addEventListener('connected', (e) => {
setSnackbarMessage('Real-time updates connected');
setSnackbarOpen(true);
});
es.addEventListener('commandToggle', (e) => {
try {
// refresh commands list to keep authoritative state
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
} catch (err) {}
});
es.addEventListener('twitchUsersUpdate', (e) => {
try {
const data = JSON.parse(e.data || '{}');
if (data && data.users) setWatchedUsers(data.users || []);
// also refresh live status
if (data && data.users && data.users.length > 0) {
const usersCsv = data.users.join(',');
axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(usersCsv)}`)
.then(resp => {
const arr = resp.data || [];
const map = {};
for (const s of arr) if (s && s.user_login) map[s.user_login.toLowerCase()] = s;
setLiveStatus(map);
})
.catch(() => {});
} else {
setLiveStatus({});
}
} catch (err) {}
});
es.addEventListener('liveNotificationsUpdate', (e) => {
try {
const data = JSON.parse(e.data || '{}');
if (typeof data.enabled !== 'undefined') setLiveEnabled(!!data.enabled);
if (data.channelId) setLiveChannelId(data.channelId || '');
if (data.twitchUser) setLiveTwitchUser(data.twitchUser || '');
} catch (err) {}
});
es.onerror = (err) => {
setSnackbarMessage('Real-time updates disconnected. Retrying...');
setSnackbarOpen(true);
// attempt reconnects handled by EventSource automatically
if (!eventTarget) return;
const onTwitchUsers = (e) => {
const data = e.detail || {};
// payload is { users: [...], guildId }
if (!data) return;
if (data.guildId && data.guildId !== guildId) return; // ignore other guilds
setWatchedUsers(data.users || []);
};
return () => { try { es && es.close(); } catch (e) {} };
}, [guildId]);
const onLiveNotifications = (e) => {
const data = e.detail || {};
if (!data) return;
if (data.guildId && data.guildId !== guildId) return;
setLiveEnabled(!!data.enabled);
setLiveChannelId(data.channelId || '');
setLiveTwitchUser(data.twitchUser || '');
};
const onCommandToggle = (e) => {
const data = e.detail || {};
if (!data) return;
if (data.guildId && data.guildId !== guildId) return;
// refresh authoritative command list
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
};
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.addEventListener('commandToggle', onCommandToggle);
return () => {
try {
eventTarget.removeEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.removeEventListener('commandToggle', onCommandToggle);
} catch (err) {}
};
}, [eventTarget, guildId]);
const handleBack = () => {
navigate(-1);
};
const handleCopy = (code) => {
try {
navigator.clipboard.writeText(code);
setSnackbarMessage('Copied to clipboard');
setSnackbarOpen(true);
} catch (e) {
// ignore
}
};
const handleDeleteInvite = async (inviteId) => {
setDeleting(prev => ({ ...prev, [inviteId]: true }));
try {
await axios.delete(`${API_BASE}/api/servers/${guildId}/invites/${inviteId}`);
setInvites(invites.filter(i => i.id !== inviteId));
} catch (e) {
// ignore
} finally {
setDeleting(prev => ({ ...prev, [inviteId]: false }));
}
};
const handleCreateInvite = async () => {
try {
const resp = await axios.post(`${API_BASE}/api/servers/${guildId}/invites`, inviteForm);
setInvites([...(invites || []), resp.data]);
setInviteForm({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
setSnackbarMessage('Invite created');
setSnackbarOpen(true);
} catch (e) {
// ignore
}
};
const handleToggleLive = async (e) => {
const enabled = e.target.checked;
setLiveEnabled(enabled);
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled, channelId: liveChannelId, twitchUser: liveTwitchUser });
setSnackbarMessage('Live notifications updated');
setSnackbarOpen(true);
} catch (err) {
// revert on error
setLiveEnabled(!enabled);
}
};
const handleAddTwitchUser = async () => {
if (!liveTwitchUser) return;
try {
const resp = await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
setWatchedUsers([...watchedUsers, resp.data]);
setLiveTwitchUser('');
setSnackbarMessage('Twitch user added');
setSnackbarOpen(true);
} catch (err) {
// ignore
}
};
const handleRemoveTwitchUser = async (username) => {
try {
await axios.delete(`${API_BASE}/api/servers/${guildId}/twitch-users/${encodeURIComponent(username)}`);
setWatchedUsers(watchedUsers.filter(u => u !== username));
setSnackbarMessage('Twitch user removed');
setSnackbarOpen(true);
} catch (err) {
// ignore
}
};
const handleCloseSnackbar = () => {
setSnackbarOpen(false);
};
// Fetch live status for watched users
useEffect(() => {
let mounted = true;
if (!watchedUsers || watchedUsers.length === 0) {
setLiveStatus({});
return;
}
const usersCsv = watchedUsers.join(',');
axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(usersCsv)}`)
.then(resp => {
if (!mounted) return;
const arr = resp.data || [];
const map = {};
for (const s of arr) {
if (s && s.user_login) map[s.user_login.toLowerCase()] = s;
}
setLiveStatus(map);
})
.catch(() => setLiveStatus({}));
return () => { mounted = false; };
}, [watchedUsers]);
const handleAutoroleSettingUpdate = (newSettings) => {
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
.then(response => {
if (response.data.success) {
setAutoroleSettings(newSettings);
}
});
}).catch(() => {});
};
const handleAutoroleToggleChange = (event) => {
@@ -255,7 +292,7 @@ const ServerSettings = () => {
if (response.data.success) {
setWelcomeLeaveSettings(newSettings);
}
});
}).catch(() => {});
}
const handleToggleChange = (type) => (event) => {
@@ -320,15 +357,13 @@ const ServerSettings = () => {
await axios.post(`${API_BASE}/api/servers/${guildId}/leave`);
setIsBotInServer(false);
} catch (error) {
console.error('Error leaving server:', error);
// show friendly message instead of noisy console error
setSnackbarMessage('Failed to make the bot leave the server. Is the backend online?');
setSnackbarOpen(true);
}
setDialogOpen(false);
};
const handleBack = () => {
navigate('/dashboard');
}
return (
<div style={{ padding: '20px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -474,7 +509,8 @@ const ServerSettings = () => {
setInvites(prev => [...prev, resp.data.invite]);
}
} catch (err) {
console.error('Error creating invite:', err);
setSnackbarMessage('Failed to create invite. Backend may be offline.');
setSnackbarOpen(true);
}
}} disabled={!isBotInServer}>Create Invite</Button>
</Box>
@@ -499,6 +535,7 @@ const ServerSettings = () => {
const input = document.createElement('input');
input.value = inv.url;
document.body.appendChild(input);
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
@@ -506,7 +543,6 @@ const ServerSettings = () => {
setSnackbarMessage('Copied invite URL to clipboard');
setSnackbarOpen(true);
} catch (err) {
console.error('Clipboard copy failed:', err);
setSnackbarMessage('Failed to copy — please copy manually');
setSnackbarOpen(true);
}
@@ -636,7 +672,7 @@ const ServerSettings = () => {
await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
setWatchedUsers(prev => [...prev.filter(u => u !== liveTwitchUser.toLowerCase()), liveTwitchUser.toLowerCase()]);
setLiveTwitchUser('');
} catch (err) { console.error('Failed to add twitch user', err); }
} catch (err) { setSnackbarMessage('Failed to add Twitch user (backend offline?)'); setSnackbarOpen(true); }
}} disabled={!isBotInServer}>Add</Button>
</Box>
@@ -663,7 +699,7 @@ const ServerSettings = () => {
<Button variant="contained" onClick={async () => {
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId });
} catch (err) { console.error('Failed to save live settings', err); }
} catch (err) { setSnackbarMessage('Failed to save live settings (backend offline?)'); setSnackbarOpen(true); }
}} disabled={!isBotInServer}>Save</Button>
</Box>
</Box>
@@ -743,7 +779,6 @@ const ServerSettings = () => {
setSnackbarMessage('Invite deleted');
setSnackbarOpen(true);
} catch (err) {
console.error('Error deleting invite:', err);
const msg = (err && err.message) || (err && err.response && err.response.data && err.response.data.message) || 'Failed to delete invite';
setSnackbarMessage(msg);
setSnackbarOpen(true);
@@ -772,7 +807,6 @@ const ServerSettings = () => {
setSnackbarMessage('Twitch user removed');
setSnackbarOpen(true);
} catch (err) {
console.error('Failed to delete twitch user', err);
setSnackbarMessage('Failed to delete twitch user');
setSnackbarOpen(true);
} finally {

View File

@@ -0,0 +1,98 @@
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
const BackendContext = createContext(null);
export function useBackend() {
return useContext(BackendContext);
}
export function BackendProvider({ children }) {
const API_BASE = process.env.REACT_APP_API_BASE || '';
const [backendOnline, setBackendOnline] = useState(false);
const [checking, setChecking] = useState(true);
const esRef = useRef(null);
const eventTargetRef = useRef(new EventTarget());
useEffect(() => {
let mounted = true;
const check = async () => {
if (!mounted) return;
setChecking(true);
try {
const resp = await fetch(`${API_BASE}/api/servers/health`);
if (!mounted) return;
setBackendOnline(!!(resp && resp.ok));
} catch (e) {
if (!mounted) return;
setBackendOnline(false);
} finally {
if (mounted) setChecking(false);
}
};
check();
const iv = setInterval(check, 5000);
return () => { mounted = false; clearInterval(iv); };
}, [API_BASE]);
// Single shared EventSource forwarded into a DOM EventTarget
useEffect(() => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
const url = `${API_BASE}/api/events`;
let es = null;
try {
es = new EventSource(url);
esRef.current = es;
} catch (err) {
// silently ignore
return;
}
const forward = (type) => (e) => {
try {
const evt = new CustomEvent(type, { detail: e.data ? JSON.parse(e.data) : null });
eventTargetRef.current.dispatchEvent(evt);
} catch (err) {
// ignore parse errors
}
};
es.addEventListener('connected', forward('connected'));
es.addEventListener('commandToggle', forward('commandToggle'));
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
es.onerror = () => {
// Let consumers react to backendOnline state changes instead of surfacing connection errors
};
return () => { try { es && es.close(); } catch (e) {} };
}, [process.env.REACT_APP_API_BASE]);
const forceCheck = async () => {
const API_BASE2 = process.env.REACT_APP_API_BASE || '';
try {
setChecking(true);
const resp = await fetch(`${API_BASE2}/api/servers/health`);
setBackendOnline(!!(resp && resp.ok));
} catch (e) {
setBackendOnline(false);
} finally {
setChecking(false);
}
};
const value = {
backendOnline,
checking,
eventTarget: eventTargetRef.current,
forceCheck,
};
return (
<BackendContext.Provider value={value}>
{children}
</BackendContext.Provider>
);
}
export default BackendContext;

View File

@@ -2,7 +2,7 @@ import React, { createContext, useState, useMemo, useContext, useEffect } from '
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { lightTheme, darkTheme, discordTheme } from '../themes';
import { UserContext } from './UserContext';
import axios from 'axios';
import { post } from '../lib/api';
export const ThemeContext = createContext();
@@ -45,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
const changeTheme = (name) => {
if (user) {
axios.post(`${process.env.REACT_APP_API_BASE || ''}/api/user/theme`, { userId: user.id, theme: name });
post('/api/user/theme', { userId: user.id, theme: name }).catch(() => {});
}
localStorage.setItem('themeName', name);
setThemeName(name);

23
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,23 @@
import axios from 'axios';
const API_BASE = process.env.REACT_APP_API_BASE || '';
const client = axios.create({
baseURL: API_BASE,
// optional: set a short timeout for UI requests
timeout: 8000,
});
export async function get(path, config) {
return client.get(path, config);
}
export async function post(path, data, config) {
return client.post(path, data, config);
}
export async function del(path, config) {
return client.delete(path, config);
}
export default client;