diff --git a/backend/index.js b/backend/index.js index 3314536..178c6b1 100644 --- a/backend/index.js +++ b/backend/index.js @@ -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 { diff --git a/backend/scripts/listServers.js b/backend/scripts/listServers.js new file mode 100644 index 0000000..968e665 --- /dev/null +++ b/backend/scripts/listServers.js @@ -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); + } +})(); diff --git a/checklist.md b/checklist.md index 1087076..af08616 100644 --- a/checklist.md +++ b/checklist.md @@ -61,4 +61,17 @@ - [x] Browser tab now shows `ECS - ` (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) \ No newline at end of file diff --git a/frontend/src/App.js b/frontend/src/App.js index 58d7c80..ac571d3 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 ( - - - } /> - } /> - } /> - } /> - } /> - + {!backendOnline ? ( + + ) : ( + <> + + + } /> + } /> + } /> + } /> + } /> + + + )} ); } +function App() { + return ( + + + + + + + + ); +} + export default App; // small helper component to set the browser tab title based on current route diff --git a/frontend/src/components/Dashboard.js b/frontend/src/components/Dashboard.js index 0a5e185..bc351c4 100644 --- a/frontend/src/components/Dashboard.js +++ b/frontend/src/components/Dashboard.js @@ -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); diff --git a/frontend/src/components/ConfirmDialog.js b/frontend/src/components/common/ConfirmDialog.js similarity index 100% rename from frontend/src/components/ConfirmDialog.js rename to frontend/src/components/common/ConfirmDialog.js diff --git a/frontend/src/components/common/MaintenancePage.js b/frontend/src/components/common/MaintenancePage.js new file mode 100644 index 0000000..beaef78 --- /dev/null +++ b/frontend/src/components/common/MaintenancePage.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { Box, Typography, Button, CircularProgress } from '@mui/material'; + +const MaintenancePage = ({ onRetry, checking }) => { + return ( + + + EhChadServices is currently under maintenance + We're checking the service status and will reload automatically when the backend is available. Please check back in a few moments. + + + {checking ? 'Checking...' : 'Idle'} + + + + + + + ); +}; + +export default MaintenancePage; diff --git a/frontend/src/components/HelpPage.js b/frontend/src/components/server/HelpPage.js similarity index 92% rename from frontend/src/components/HelpPage.js rename to frontend/src/components/server/HelpPage.js index 9717677..ed5ac79 100644 --- a/frontend/src/components/HelpPage.js +++ b/frontend/src/components/server/HelpPage.js @@ -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); diff --git a/frontend/src/components/ServerSettings.js b/frontend/src/components/server/ServerSettings.js similarity index 84% rename from frontend/src/components/ServerSettings.js rename to frontend/src/components/server/ServerSettings.js index 4568db8..af1c9fe 100644 --- a/frontend/src/components/ServerSettings.js +++ b/frontend/src/components/server/ServerSettings.js @@ -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 (
@@ -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 @@ -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 @@ -663,7 +699,7 @@ const ServerSettings = () => { @@ -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 { diff --git a/frontend/src/contexts/BackendContext.js b/frontend/src/contexts/BackendContext.js new file mode 100644 index 0000000..46037f0 --- /dev/null +++ b/frontend/src/contexts/BackendContext.js @@ -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 ( + + {children} + + ); +} + +export default BackendContext; diff --git a/frontend/src/contexts/ThemeContext.js b/frontend/src/contexts/ThemeContext.js index 69bc606..7f106c5 100644 --- a/frontend/src/contexts/ThemeContext.js +++ b/frontend/src/contexts/ThemeContext.js @@ -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); diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..3430a89 --- /dev/null +++ b/frontend/src/lib/api.js @@ -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;