Update backend, DB, Commands, Live Reloading

This commit is contained in:
2025-10-09 02:17:33 -04:00
parent 6a78ec6453
commit 2ae7202445
22 changed files with 1283 additions and 249 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { UserProvider } from './contexts/UserContext';

View File

@@ -2,7 +2,7 @@ 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 { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, Snackbar, Alert } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
@@ -40,13 +40,22 @@ const ServerSettings = () => {
// SSE connection status (not currently displayed)
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
const [confirmDeleteKick, setConfirmDeleteKick] = useState(false);
const [pendingKickUser, setPendingKickUser] = useState(null);
const [commandsExpanded, setCommandsExpanded] = useState(false);
const [liveExpanded, setLiveExpanded] = useState(false);
const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
const [liveEnabled, setLiveEnabled] = useState(false);
const [liveChannelId, setLiveChannelId] = useState('');
const [liveTwitchUser, setLiveTwitchUser] = useState('');
const [liveMessage, setLiveMessage] = useState('');
const [liveCustomMessage, setLiveCustomMessage] = useState('');
const [watchedUsers, setWatchedUsers] = useState([]);
const [liveStatus, setLiveStatus] = useState({});
const [commandsExpanded, setCommandsExpanded] = useState(false);
const [liveTabValue, setLiveTabValue] = useState(0);
const [kickUsers, setKickUsers] = useState([]);
const [kickStatus, setKickStatus] = useState({});
const [kickUser, setKickUser] = useState('');
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: {
enabled: false,
@@ -127,14 +136,18 @@ const ServerSettings = () => {
// Fetch invites
// Fetch live notifications settings and watched users
axios.get(`${API_BASE}/api/servers/${guildId}/live-notifications`).then(resp => {
const s = resp.data || { enabled: false, twitchUser: '', channelId: '' };
const s = resp.data || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' };
setLiveEnabled(!!s.enabled);
setLiveChannelId(s.channelId || '');
setLiveTwitchUser(s.twitchUser || '');
setLiveMessage(s.message || '');
setLiveCustomMessage(s.customMessage || '');
}).catch(() => {});
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
axios.get(`${API_BASE}/api/servers/${guildId}/kick-users`).then(resp => setKickUsers(resp.data || [])).catch(() => setKickUsers([]));
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
// Open commands accordion if navigated from Help back button
@@ -165,6 +178,16 @@ const ServerSettings = () => {
setLiveEnabled(!!data.enabled);
setLiveChannelId(data.channelId || '');
setLiveTwitchUser(data.twitchUser || '');
setLiveMessage(data.message || '');
setLiveCustomMessage(data.customMessage || '');
};
const onKickUsers = (e) => {
const data = e.detail || {};
// payload is { users: [...], guildId }
if (!data) return;
if (data.guildId && data.guildId !== guildId) return; // ignore other guilds
setKickUsers(data.users || []);
};
const onCommandToggle = (e) => {
@@ -176,12 +199,14 @@ const ServerSettings = () => {
};
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.addEventListener('commandToggle', onCommandToggle);
return () => {
try {
eventTarget.removeEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.removeEventListener('commandToggle', onCommandToggle);
} catch (err) {}
@@ -192,77 +217,18 @@ const ServerSettings = () => {
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 });
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled, channelId: liveChannelId, twitchUser: liveTwitchUser, message: liveMessage, customMessage: liveCustomMessage });
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);
};
@@ -364,6 +330,54 @@ const ServerSettings = () => {
setDialogOpen(false);
};
// Poll Twitch live status for watched users (simple interval). Avoid spamming when list empty or feature disabled.
useEffect(() => {
let timer = null;
const poll = async () => {
if (!watchedUsers || watchedUsers.length === 0) return;
try {
const csv = watchedUsers.join(',');
const resp = await axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(csv)}`);
const arr = resp.data || [];
const map = {};
for (const s of arr) {
const login = (s.user_login || '').toLowerCase();
map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count };
}
setLiveStatus(map);
} catch (e) {
// network errors ignored
}
};
poll();
timer = setInterval(poll, 15000); // 15s interval
return () => { if (timer) clearInterval(timer); };
}, [watchedUsers]);
// Poll Kick live status for watched users (simple interval). Avoid spamming when list empty or feature disabled.
useEffect(() => {
let timer = null;
const poll = async () => {
if (!kickUsers || kickUsers.length === 0) return;
try {
const csv = kickUsers.join(',');
const resp = await axios.get(`${API_BASE}/api/kick/streams?users=${encodeURIComponent(csv)}`);
const arr = resp.data || [];
const map = {};
for (const s of arr) {
const login = (s.user_login || '').toLowerCase();
map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count };
}
setKickStatus(map);
} catch (e) {
// network errors ignored
}
};
poll();
timer = setInterval(poll, 15000); // 15s interval
return () => { if (timer) clearInterval(timer); };
}, [kickUsers]);
return (
<div style={{ padding: '20px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -650,59 +664,143 @@ const ServerSettings = () => {
</AccordionDetails>
</Accordion>
{/* Live Notifications Accordion */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px' }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Live Notifications</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
<Box sx={{ marginTop: '10px' }}>
<FormControl fullWidth disabled={!isBotInServer}>
<Select value={liveChannelId} onChange={(e) => setLiveChannelId(e.target.value)} displayEmpty>
<MenuItem value="">(Select channel)</MenuItem>
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
</Select>
</FormControl>
<Box sx={{ opacity: isBotInServer ? 1 : 0.5 }}>
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable this feature.</Typography>}
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Tabs value={liveTabValue} onChange={(e, newValue) => {
// Prevent switching to Kick tab (index 1) since it's disabled
if (newValue !== 1) {
setLiveTabValue(newValue);
}
}} sx={{ borderBottom: 1, borderColor: 'divider', '& .MuiTabs-indicator': { backgroundColor: 'primary.main' } }}>
<Tab label="Twitch" sx={{ textTransform: 'none', fontWeight: 'medium' }} />
<Tab label="Kick (Disabled)" sx={{ textTransform: 'none', fontWeight: 'medium', opacity: 0.5, cursor: 'not-allowed' }} disabled />
</Tabs>
{liveTabValue === 0 && (
<Box sx={{ p: 3 }}>
<FormControlLabel control={<Switch checked={liveEnabled} onChange={handleToggleLive} />} label="Enabled" sx={{ mb: 2 }} />
<FormControl fullWidth sx={{ mb: 2 }} disabled={!isBotInServer}>
<Select value={liveChannelId} onChange={(e) => setLiveChannelId(e.target.value)} displayEmpty>
<MenuItem value="">(Select channel)</MenuItem>
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
</Select>
</FormControl>
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<TextField label="Twitch username" value={liveTwitchUser} onChange={(e) => setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} />
<Button variant="contained" onClick={async () => {
if (!liveTwitchUser) return;
try {
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) { setSnackbarMessage('Failed to add Twitch user (backend offline?)'); setSnackbarOpen(true); }
}} disabled={!isBotInServer}>Add</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<TextField label="Twitch username" value={liveTwitchUser} onChange={(e) => setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} />
<Button variant="contained" onClick={async () => {
if (!liveTwitchUser) return;
try {
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) { setSnackbarMessage('Failed to add Twitch user (backend offline?)'); setSnackbarOpen(true); }
}} disabled={!isBotInServer}>Add</Button>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2">Watched Users</Typography>
{watchedUsers.length === 0 && <Typography>No users added</Typography>}
{watchedUsers.map(u => (
<Box key={u} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography>{u}</Typography>
{liveStatus[u] && liveStatus[u].is_live && (
<Button size="small" color="error" href={liveStatus[u].url} target="_blank" rel="noopener">Watch Live</Button>
)}
</Box>
<Box>
<Button size="small" onClick={() => { setPendingTwitchUser(u); setConfirmDeleteTwitch(true); }}>Delete</Button>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Watched Users</Typography>
{watchedUsers.length === 0 && <Typography>No users added</Typography>}
{watchedUsers.map(u => (
<Box key={u} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1, p: 1, border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography>{u}</Typography>
{liveStatus[u] && liveStatus[u].is_live && (
<Button size="small" color="error" href={liveStatus[u].url} target="_blank" rel="noopener">Watch Live</Button>
)}
</Box>
<Box>
<Button size="small" onClick={() => { setPendingTwitchUser(u); setConfirmDeleteTwitch(true); }}>Delete</Button>
</Box>
</Box>
))}
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2">Notification Message Mode</Typography>
<FormControl component="fieldset" sx={{ mt: 1 }} disabled={!isBotInServer || !liveEnabled}>
<RadioGroup
row
value={liveCustomMessage ? 'custom' : 'default'}
onChange={(e) => {
const mode = e.target.value;
if (mode === 'default') {
setLiveCustomMessage('');
if (!liveMessage) setLiveMessage('🔴 {user} is now live!');
} else {
setLiveCustomMessage(liveCustomMessage || liveMessage || '🔴 {user} is now live!');
}
}}
>
<FormControlLabel value="default" control={<Radio />} label="Default" />
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
</RadioGroup>
</FormControl>
{liveCustomMessage ? (
<TextField
label="Custom Message"
value={liveCustomMessage}
onChange={(e) => setLiveCustomMessage(e.target.value)}
fullWidth
sx={{ mt: 2 }}
placeholder="Your custom announcement text"
disabled={!isBotInServer || !liveEnabled}
/>
) : (
<Typography variant="body2" sx={{ mt: 2 }}>
Using default message: <strong>{liveMessage || '🔴 {user} is now live!'}</strong>
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2, gap: 1 }}>
{liveCustomMessage && (
<Button variant="text" size="small" onClick={() => setLiveCustomMessage('')} disabled={!isBotInServer || !liveEnabled}>Use Default</Button>
)}
<Button variant="outlined" size="small" onClick={async () => {
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, {
enabled: liveEnabled,
channelId: liveChannelId,
twitchUser: '',
message: liveMessage || '🔴 {user} is now live!',
customMessage: liveCustomMessage
});
setSnackbarMessage('Notification message updated');
setSnackbarOpen(true);
} catch (err) {
setSnackbarMessage('Failed to update message');
setSnackbarOpen(true);
}
}} disabled={!isBotInServer || !liveEnabled}>Apply</Button>
</Box>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
<Button variant="contained" onClick={async () => {
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId, message: liveMessage, customMessage: liveCustomMessage });
setSnackbarMessage('Live notification settings saved');
setSnackbarOpen(true);
} catch (err) { setSnackbarMessage('Failed to save live settings (backend offline?)'); setSnackbarOpen(true); }
}} disabled={!isBotInServer}>Save</Button>
</Box>
</Box>
<FormControlLabel control={<Switch checked={liveEnabled} onChange={(e) => setLiveEnabled(e.target.checked)} />} label="Enabled" sx={{ mt: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button variant="contained" onClick={async () => {
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId });
} catch (err) { setSnackbarMessage('Failed to save live settings (backend offline?)'); setSnackbarOpen(true); }
}} disabled={!isBotInServer}>Save</Button>
)}
{liveTabValue === 1 && (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" sx={{ mb: 2, color: 'text.secondary' }}>
Kick Live Notifications (Disabled)
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Kick live notifications are temporarily disabled. This feature will be re-enabled in a future update.
</Typography>
</Box>
</Box>
)}
</Box>
</Box>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
@@ -816,11 +914,28 @@ const ServerSettings = () => {
title="Delete Twitch User"
message={`Are you sure you want to remove ${pendingTwitchUser || ''} from the watch list?`}
/>
<Snackbar open={snackbarOpen} autoHideDuration={4000} onClose={handleCloseSnackbar}>
<Alert onClose={handleCloseSnackbar} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
{/* Confirm dialog for deleting a kick user from watched list */}
<ConfirmDialog
open={confirmDeleteKick}
onClose={() => { setConfirmDeleteKick(false); setPendingKickUser(null); }}
onConfirm={async () => {
if (!pendingKickUser) { setConfirmDeleteKick(false); return; }
setConfirmDeleteKick(false);
try {
await axios.delete(`${API_BASE}/api/servers/${guildId}/kick-users/${encodeURIComponent(pendingKickUser)}`);
setKickUsers(prev => prev.filter(x => x !== pendingKickUser));
setSnackbarMessage('Kick user removed');
setSnackbarOpen(true);
} catch (err) {
setSnackbarMessage('Failed to delete kick user');
setSnackbarOpen(true);
} finally {
setPendingKickUser(null);
}
}}
title="Delete Kick User"
message={`Are you sure you want to remove ${pendingKickUser || ''} from the watch list?`}
/>
</div>
);
};

View File

@@ -66,7 +66,7 @@ export function BackendProvider({ children }) {
};
return () => { try { es && es.close(); } catch (e) {} };
}, [process.env.REACT_APP_API_BASE]);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const forceCheck = async () => {
const API_BASE2 = process.env.REACT_APP_API_BASE || '';