live updates and file organization
This commit is contained in:
@@ -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 {
|
||||
|
||||
12
backend/scripts/listServers.js
Normal file
12
backend/scripts/listServers.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
13
checklist.md
13
checklist.md
@@ -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)
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
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 />
|
||||
{!backendOnline ? (
|
||||
<MaintenancePage onRetry={handleRetry} checking={checking} />
|
||||
) : (
|
||||
<>
|
||||
<NavBar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
@@ -25,12 +37,26 @@ function App() {
|
||||
<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
|
||||
|
||||
@@ -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);
|
||||
|
||||
22
frontend/src/components/common/MaintenancePage.js
Normal file
22
frontend/src/components/common/MaintenancePage.js
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(response => {
|
||||
setCommandsList(response.data || []);
|
||||
})
|
||||
.catch(() => setCommandsList([]));
|
||||
}).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}/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 || []);
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
return () => { try { es && es.close(); } catch (e) {} };
|
||||
}, [guildId]);
|
||||
|
||||
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 {
|
||||
98
frontend/src/contexts/BackendContext.js
Normal file
98
frontend/src/contexts/BackendContext.js
Normal 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;
|
||||
@@ -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
23
frontend/src/lib/api.js
Normal 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;
|
||||
Reference in New Issue
Block a user