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

@@ -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 {