Moderation Update

This commit is contained in:
2025-10-09 06:13:48 -04:00
parent 2ae7202445
commit ff10bb3183
20 changed files with 2056 additions and 381 deletions

View File

@@ -3,7 +3,7 @@ import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-route
import { ThemeProvider } from './contexts/ThemeContext';
import { UserProvider } from './contexts/UserContext';
import { BackendProvider, useBackend } from './contexts/BackendContext';
import { CssBaseline } from '@mui/material';
import { CssBaseline, Box } from '@mui/material';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import ServerSettings from './components/server/ServerSettings';
@@ -11,6 +11,7 @@ import NavBar from './components/NavBar';
import HelpPage from './components/server/HelpPage';
import DiscordPage from './components/DiscordPage';
import MaintenancePage from './components/common/MaintenancePage';
import Footer from './components/common/Footer';
function AppInner() {
const { backendOnline, checking, forceCheck } = useBackend();
@@ -23,23 +24,41 @@ function AppInner() {
<UserProvider>
<ThemeProvider>
<CssBaseline />
<Router>
<TitleSetter />
{!backendOnline ? (
<MaintenancePage onRetry={handleRetry} checking={checking} />
) : (
<>
<NavBar />
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
</>
)}
</Router>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}
>
<Router>
<TitleSetter />
{!backendOnline ? (
<MaintenancePage onRetry={handleRetry} checking={checking} />
) : (
<>
<NavBar />
<Box
component="main"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
</Box>
<Footer />
</>
)}
</Router>
</Box>
</ThemeProvider>
</UserProvider>
);

View File

@@ -1,10 +1,13 @@
import React, { useState, useEffect, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button } from '@mui/material';
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button, Container } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { UserContext } from '../contexts/UserContext';
import { useBackend } from '../contexts/BackendContext';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
import DashboardIcon from '@mui/icons-material/Dashboard';
import WavingHandIcon from '@mui/icons-material/WavingHand';
import { get, post } from '../lib/api';
import ConfirmDialog from './common/ConfirmDialog';
@@ -13,6 +16,7 @@ const Dashboard = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, setUser } = useContext(UserContext);
const { eventTarget } = useBackend();
const [guilds, setGuilds] = useState([]);
const [botStatus, setBotStatus] = useState({});
@@ -89,6 +93,25 @@ const Dashboard = () => {
fetchStatuses();
}, [guilds, API_BASE]);
// Listen for bot status updates
useEffect(() => {
if (!eventTarget) return;
const onBotStatusUpdate = (event) => {
const data = JSON.parse(event.data);
setBotStatus(prev => ({
...prev,
[data.guildId]: data.isBotInServer
}));
};
eventTarget.addEventListener('botStatusUpdate', onBotStatusUpdate);
return () => {
eventTarget.removeEventListener('botStatusUpdate', onBotStatusUpdate);
};
}, [eventTarget]);
// Dashboard no longer loads live settings; that's on the server settings page
// Live notifications handlers were removed from Dashboard
@@ -139,18 +162,32 @@ const Dashboard = () => {
const handleSnackbarClose = () => setSnackbarOpen(false);
return (
<div style={{ padding: 20 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Container maxWidth="lg" sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
<Box sx={{ mb: 2 }}>
<Box>
<Typography variant="h4" gutterBottom>Dashboard</Typography>
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<DashboardIcon sx={{ mr: 1, fontSize: { xs: '2rem', sm: '2.5rem' } }} />
<Typography variant={{ xs: 'h4', sm: 'h3' }}>Dashboard</Typography>
</Box>
{user && <Box sx={{ display: 'flex', alignItems: 'center', mt: { xs: 4, sm: 5 }, mb: { xs: 4, sm: 5 } }}>
<WavingHandIcon sx={{ mr: 1, color: 'text.secondary' }} />
<Typography
variant={{ xs: 'h3', sm: 'h2' }}
sx={{
fontWeight: 300,
color: 'text.secondary'
}}
>
Welcome back, {user.username}
</Typography>
</Box>}
</Box>
</Box>
<Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
<Grid container spacing={3} justifyContent="center">
{guilds.map(guild => (
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Grid item xs={12} sm={6} md={6} lg={4} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Card
onClick={() => handleCardClick(guild)}
@@ -163,6 +200,11 @@ const Dashboard = () => {
transform: 'translateY(-5px)',
boxShadow: '0 12px 24px rgba(0,0,0,0.3)',
},
'&:active': {
transform: 'translateY(-2px) scale(0.98)',
transition: 'transform 0.1s ease-in-out',
boxShadow: '0 8px 16px rgba(0,0,0,0.4)',
},
width: '100%',
height: '100%',
display: 'flex',
@@ -171,30 +213,66 @@ const Dashboard = () => {
overflow: 'hidden',
}}
>
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 2 }}>
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: { xs: 1.5, sm: 2 } }}>
<Box
component="img"
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
sx={{
width: 80,
height: 80,
width: { xs: 60, sm: 80 },
height: { xs: 60, sm: 80 },
borderRadius: '50%',
mb: 2,
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
}}
/>
<Typography variant="h6" sx={{ fontWeight: 700, textAlign: 'center', mb: 1 }}>{guild.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<Typography
variant="h6"
sx={{
fontWeight: 700,
textAlign: 'center',
mb: 1,
fontSize: { xs: '1rem', sm: '1.25rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
lineHeight: 1.2,
minHeight: { xs: '2.4rem', sm: '2.5rem' },
}}
>
{guild.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, flexWrap: 'wrap' }}>
{botStatus[guild.id] ? (
<Button variant="contained" color="error" size="small" onClick={(e) => handleLeaveBot(e, guild)} startIcon={<RemoveCircleOutlineIcon />}>
<Button
variant="contained"
color="error"
size="small"
onClick={(e) => handleLeaveBot(e, guild)}
startIcon={<RemoveCircleOutlineIcon />}
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
Leave
</Button>
) : (
<Button variant="contained" color="success" size="small" onClick={(e) => handleInviteBot(e, guild)} startIcon={<PersonAddIcon />}>
<Button
variant="contained"
color="success"
size="small"
onClick={(e) => handleInviteBot(e, guild)}
startIcon={<PersonAddIcon />}
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
Invite
</Button>
)}
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }} aria-label="server menu">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }}
aria-label="server menu"
sx={{ ml: { xs: 0, sm: 1 } }}
>
<MoreVertIcon />
</IconButton>
</Box>
@@ -206,26 +284,26 @@ const Dashboard = () => {
))}
</Grid>
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
</Menu>
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
</Menu>
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
<ConfirmDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onConfirm={handleConfirmLeave}
title="Confirm Leave"
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
/>
</div>
<ConfirmDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onConfirm={handleConfirmLeave}
title="Confirm Leave"
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
/>
</Container>
);
};

View File

@@ -25,7 +25,7 @@ const NavBar = () => {
const closeMenu = () => { setAnchorEl(null); setOpen(false); };
return (
<AppBar position="static" color="transparent" elevation={0} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<AppBar position="static" color="default" elevation={1} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.12)' }}>
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: { xs: 2, sm: 4 } }}>
<Box>
<IconButton onClick={toggleOpen} aria-label="menu" size="large" sx={{ bgcolor: open ? 'primary.main' : 'transparent', color: open ? 'white' : 'text.primary' }}>
@@ -33,8 +33,12 @@ const NavBar = () => {
</IconButton>
</Box>
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'block', sm: 'none' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
ECS
</Typography>
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
ECS - EHDCHADSWORTH
EhChadServices
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
const Footer = () => {
return (
<Box
component="footer"
sx={{
py: { xs: 1, sm: 2 },
px: { xs: 1, sm: 2 },
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[200]
: theme.palette.grey[800],
textAlign: 'center',
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: { xs: '0.75rem', sm: '0.875rem' },
}}
>
© ehchadservices.com 2025
</Typography>
</Box>
);
};;
export default Footer;

View File

@@ -26,10 +26,10 @@ const HelpPage = () => {
}
return (
<div style={{ padding: 20 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
<Typography variant="h5">Commands List</Typography>
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, mb: 2 }}>
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
<Typography variant={{ xs: 'h5', sm: 'h5' }}>Commands List</Typography>
</Box>
<Box sx={{ marginTop: 2 }}>
{commands.length === 0 && <Typography>No commands available.</Typography>}
@@ -43,7 +43,7 @@ const HelpPage = () => {
</Box>
))}
</Box>
</div>
</Box>
);
}

View File

@@ -1,14 +1,15 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useContext } 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, Tabs, Tab, Snackbar, Alert } from '@mui/material';
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, InputLabel, Snackbar, Alert, Autocomplete } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
import ConfirmDialog from '../common/ConfirmDialog';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
import { UserContext } from '../../contexts/UserContext';
// Use a relative API base by default so the frontend talks to the same origin that served it.
// In development you can set REACT_APP_API_BASE to a full URL if needed.
@@ -18,6 +19,7 @@ const ServerSettings = () => {
const { guildId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { user } = useContext(UserContext);
// settings state removed (not used) to avoid lint warnings
const [isBotInServer, setIsBotInServer] = useState(false);
@@ -35,8 +37,6 @@ const ServerSettings = () => {
const [deleting, setDeleting] = useState({});
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
// SSE connection status (not currently displayed)
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
@@ -44,18 +44,6 @@ const ServerSettings = () => {
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 [liveTabValue, setLiveTabValue] = useState(0);
const [kickUsers, setKickUsers] = useState([]);
const [kickStatus, setKickStatus] = useState({});
const [kickUser, setKickUser] = useState('');
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: {
enabled: false,
@@ -70,6 +58,39 @@ const ServerSettings = () => {
customMessage: '',
},
});
const [adminLogsSettings, setAdminLogsSettings] = useState({
enabled: false,
channelId: '',
commands: { kick: true, ban: true, timeout: true }
});
const [adminLogs, setAdminLogs] = useState([]);
const [selectedChannelId, setSelectedChannelId] = useState('');
const [moderationTarget, setModerationTarget] = useState('');
const [moderationReason, setModerationReason] = useState('');
const [timeoutDuration, setTimeoutDuration] = useState('');
const [serverMembers, setServerMembers] = useState([]);
// Add back missing state variables that are still used in the code
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [inviteForm, setInviteForm] = useState({
maxUses: '',
expiresAt: '',
channelId: ''
});
const [liveTabValue, setLiveTabValue] = useState(0);
const [liveEnabled, setLiveEnabled] = useState(false);
const [liveChannelId, setLiveChannelId] = useState('');
const [liveTwitchUser, setLiveTwitchUser] = useState('');
const [liveMessage, setLiveMessage] = useState('Twitch user {user} is now live!');
const [liveCustomMessage, setLiveCustomMessage] = useState('');
const [watchedUsers, setWatchedUsers] = useState([]);
const [kickUsers, setKickUsers] = useState([]);
const [liveStatus, setLiveStatus] = useState({});
// Confirm dialog states for admin logs
const [deleteLogDialog, setDeleteLogDialog] = useState({ open: false, logId: null, logAction: '' });
const [deleteAllLogsDialog, setDeleteAllLogsDialog] = useState(false);
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
@@ -103,13 +124,13 @@ const ServerSettings = () => {
}).catch(() => {
// ignore when offline
});
// Fetch bot status
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`).then(response => {
setIsBotInServer(response.data.isBotInServer);
}).catch(() => setIsBotInServer(false));
// Fetch channels
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
setChannels(response.data);
}).catch(() => {
setChannels([]);
});
// Fetch channels - moved to separate useEffect to depend on bot status
// Fetch welcome/leave settings
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
@@ -144,18 +165,46 @@ const ServerSettings = () => {
setLiveCustomMessage(s.customMessage || '');
}).catch(() => {});
// Fetch admin logs settings
axios.get(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`).then(response => {
if (response.data) {
setAdminLogsSettings(response.data);
setSelectedChannelId(response.data.channelId || '');
}
}).catch(() => {
// ignore
});
// Fetch admin logs
axios.get(`${API_BASE}/api/servers/${guildId}/admin-logs`).then(response => {
setAdminLogs(response.data || []);
}).catch(() => setAdminLogs([]));
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([]));
// Fetch server members for moderation
axios.get(`${API_BASE}/api/servers/${guildId}/members`).then(resp => setServerMembers(resp.data || [])).catch(() => setServerMembers([]));
// Open commands accordion if navigated from Help back button
if (location.state && location.state.openCommands) {
setCommandsExpanded(true);
}
}, [guildId, location.state]);
}, [guildId, location.state, isBotInServer]);
// Fetch channels only when bot is in server
useEffect(() => {
if (!guildId || !isBotInServer) return;
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
setChannels(response.data);
}).catch(() => {
setChannels([]);
});
}, [guildId, isBotInServer]);
// Listen to backend events for live notifications and twitch user updates
const { eventTarget } = useBackend();
@@ -198,10 +247,35 @@ const ServerSettings = () => {
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
};
const onAdminLogAdded = (e) => {
const data = e.detail || {};
if (!data) return;
if (data.guildId && data.guildId !== guildId) return;
// Add the new log to the beginning of the list
setAdminLogs(prev => [data.log, ...prev.slice(0, 49)]); // Keep only latest 50
};
const onAdminLogDeleted = (e) => {
const data = e.detail || {};
if (!data) return;
if (data.guildId && data.guildId !== guildId) return;
setAdminLogs(prev => prev.filter(log => log.id !== data.logId));
};
const onAdminLogsCleared = (e) => {
const data = e.detail || {};
if (!data) return;
if (data.guildId && data.guildId !== guildId) return;
setAdminLogs([]);
};
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.addEventListener('commandToggle', onCommandToggle);
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
return () => {
try {
@@ -209,12 +283,15 @@ const ServerSettings = () => {
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.removeEventListener('commandToggle', onCommandToggle);
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
} catch (err) {}
};
}, [eventTarget, guildId]);
const handleBack = () => {
navigate(-1);
navigate('/dashboard');
};
const handleToggleLive = async (e) => {
@@ -229,10 +306,6 @@ const ServerSettings = () => {
}
};
const handleCloseSnackbar = () => {
setSnackbarOpen(false);
};
const handleAutoroleSettingUpdate = (newSettings) => {
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
.then(response => {
@@ -330,6 +403,100 @@ const ServerSettings = () => {
setDialogOpen(false);
};
const handleAdminLogsSettingChange = async (key, value) => {
const newSettings = { ...adminLogsSettings, [key]: value };
setAdminLogsSettings(newSettings);
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`, newSettings);
} catch (error) {
setSnackbarMessage('Failed to update admin logs settings.');
setSnackbarOpen(true);
}
};
const handleAdminLogsCommandChange = async (command, enabled) => {
const newSettings = {
...adminLogsSettings,
commands: { ...adminLogsSettings.commands, [command]: enabled }
};
setAdminLogsSettings(newSettings);
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`, newSettings);
} catch (error) {
setSnackbarMessage('Failed to update admin logs settings.');
setSnackbarOpen(true);
}
};
const handleDeleteLog = async (logId) => {
try {
await axios.delete(`${API_BASE}/api/servers/${guildId}/admin-logs/${logId}`);
setAdminLogs(prev => prev.filter(log => log.id !== logId));
setSnackbarMessage('Log deleted successfully.');
setSnackbarOpen(true);
} catch (error) {
setSnackbarMessage('Failed to delete log.');
setSnackbarOpen(true);
}
setDeleteLogDialog({ open: false, logId: null, logAction: '' });
};
const handleDeleteAllLogs = async () => {
try {
await axios.delete(`${API_BASE}/api/servers/${guildId}/admin-logs`);
setAdminLogs([]);
setSnackbarMessage('All logs deleted successfully.');
setSnackbarOpen(true);
} catch (error) {
setSnackbarMessage('Failed to delete all logs.');
setSnackbarOpen(true);
}
setDeleteAllLogsDialog(false);
};
const handleModerationAction = async (action) => {
// Validate reason has at least 3 words
const reasonWords = moderationReason.trim().split(/\s+/);
if (reasonWords.length < 3) {
setSnackbarMessage('Reason must be at least 3 words long.');
setSnackbarOpen(true);
return;
}
try {
const payload = {
action,
target: moderationTarget.trim(),
reason: moderationReason.trim(),
moderator: {
id: user?.id,
username: user?.username,
global_name: user?.global_name,
discriminator: user?.discriminator
}
};
if (action === 'timeout') {
payload.duration = parseInt(timeoutDuration);
}
await axios.post(`${API_BASE}/api/servers/${guildId}/moderate`, payload);
setSnackbarMessage(`${action.charAt(0).toUpperCase() + action.slice(1)} action completed successfully!`);
setSnackbarOpen(true);
setModerationTarget('');
setModerationReason('');
if (action === 'timeout') {
setTimeoutDuration('');
}
} catch (error) {
setSnackbarMessage(`Failed to ${action} user.`);
setSnackbarOpen(true);
}
};
// Poll Twitch live status for watched users (simple interval). Avoid spamming when list empty or feature disabled.
useEffect(() => {
let timer = null;
@@ -368,7 +535,7 @@ const ServerSettings = () => {
const login = (s.user_login || '').toLowerCase();
map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count };
}
setKickStatus(map);
// kickStatus not used since Kick functionality is disabled
} catch (e) {
// network errors ignored
}
@@ -379,13 +546,31 @@ const ServerSettings = () => {
}, [kickUsers]);
return (
<div style={{ padding: '20px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
mb: 2
}}>
<IconButton onClick={handleBack} sx={{ borderRadius: '50%', boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)' }}>
<ArrowBackIcon />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h4" component="h1" sx={{ margin: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, flexWrap: 'wrap' }}>
<Typography
variant={{ xs: 'h5', sm: 'h4' }}
component="h1"
sx={{
margin: 0,
textAlign: { xs: 'center', sm: 'left' },
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{server ? `Server Settings for ${server.name}` : 'Loading...'}
</Typography>
{isBotInServer ? (
@@ -418,7 +603,8 @@ const ServerSettings = () => {
{(() => {
const protectedOrder = ['help', 'manage-commands'];
const protectedCmds = protectedOrder.map(name => commandsList.find(c => c.name === name)).filter(Boolean);
const otherCmds = (commandsList || []).filter(c => !protectedOrder.includes(c.name)).sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
const adminCommands = ['kick', 'ban', 'timeout'];
const otherCmds = (commandsList || []).filter(c => !protectedOrder.includes(c.name) && !adminCommands.includes(c.name)).sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
return (
<>
{protectedCmds.map(cmd => (
@@ -664,7 +850,7 @@ const ServerSettings = () => {
</AccordionDetails>
</Accordion>
{/* Live Notifications Accordion */}
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px' }}>
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Live Notifications</Typography>
</AccordionSummary>
@@ -834,7 +1020,282 @@ const ServerSettings = () => {
<Typography variant="h6">Admin Commands</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>Coming soon...</Typography>
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable admin commands.</Typography>}
<Accordion sx={{ marginTop: '10px' }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1">Moderation Commands</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>/kick</Typography>
<Typography variant="body2">Kick a user from the server</Typography>
<Typography variant="caption" color="text.secondary">Requires: Kick Members permission</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>/ban</Typography>
<Typography variant="body2">Ban a user from the server</Typography>
<Typography variant="caption" color="text.secondary">Requires: Ban Members permission</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>/timeout</Typography>
<Typography variant="body2">Timeout a user in the server</Typography>
<Typography variant="caption" color="text.secondary">Requires: Moderate Members permission</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
</Box>
</Box>
</Box>
</AccordionDetails>
</Accordion>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Admin Logs</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable admin logs.</Typography>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControlLabel
control={
<Switch
checked={adminLogsSettings?.enabled || false}
onChange={(e) => handleAdminLogsSettingChange('enabled', e.target.checked)}
disabled={!isBotInServer}
/>
}
label="Enable Admin Logging"
/>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel>Log Channel</InputLabel>
<Select
value={selectedChannelId}
onChange={(e) => {
setSelectedChannelId(e.target.value);
handleAdminLogsSettingChange('channelId', e.target.value);
}}
disabled={!isBotInServer || !adminLogsSettings?.enabled}
label="Log Channel"
>
{channels.filter(channel => channel.type === 0).map(channel => (
<MenuItem key={channel.id} value={channel.id}>
# {channel.name}
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>Log Commands:</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, pl: 2 }}>
<FormControlLabel
control={
<Switch
checked={adminLogsSettings?.commands?.kick !== false}
onChange={(e) => handleAdminLogsCommandChange('kick', e.target.checked)}
disabled={!isBotInServer || !adminLogsSettings?.enabled}
/>
}
label="Log Kick Actions"
/>
<FormControlLabel
control={
<Switch
checked={adminLogsSettings?.commands?.ban !== false}
onChange={(e) => handleAdminLogsCommandChange('ban', e.target.checked)}
disabled={!isBotInServer || !adminLogsSettings?.enabled}
/>
}
label="Log Ban Actions"
/>
<FormControlLabel
control={
<Switch
checked={adminLogsSettings?.commands?.timeout !== false}
onChange={(e) => handleAdminLogsCommandChange('timeout', e.target.checked)}
disabled={!isBotInServer || !adminLogsSettings?.enabled}
/>
}
label="Log Timeout Actions"
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2, mb: 1 }}>
<Typography variant="subtitle1">Recent Logs:</Typography>
{adminLogs.length > 0 && (
<Button
variant="outlined"
color="error"
size="small"
onClick={() => setDeleteAllLogsDialog(true)}
startIcon={<DeleteIcon />}
>
Delete All Logs
</Button>
)}
</Box>
<Box sx={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #ddd', borderRadius: 1, p: 1 }}>
{adminLogs.length === 0 ? (
<Typography>No logs available.</Typography>
) : (
adminLogs.map(log => (
<Box key={log.id} sx={{ p: 1, border: '1px solid #eee', mb: 1, borderRadius: 1, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{log.action.toUpperCase()} - {log.targetUsername || 'Unknown User'} by {log.moderatorUsername || 'Unknown User'}
</Typography>
<Typography variant="body2">Reason: {log.reason}</Typography>
{log.duration && <Typography variant="body2">Duration: {log.duration}</Typography>}
<Typography variant="caption" color="text.secondary">
{new Date(log.timestamp).toLocaleString()}
</Typography>
</Box>
<IconButton
size="small"
color="error"
onClick={() => setDeleteLogDialog({ open: true, logId: log.id, logAction: log.action })}
sx={{ ml: 1 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
))
)}
</Box>
</Box>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Moderation Actions</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable moderation actions.</Typography>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
Perform moderation actions directly from the web interface. You must have the appropriate Discord permissions.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Autocomplete
options={serverMembers}
getOptionLabel={(option) => {
if (typeof option === 'string') return option;
return option.globalName || option.username || option.displayName || option.id;
}}
value={serverMembers.find(member => member.id === moderationTarget) || null}
onChange={(event, newValue) => {
if (newValue) {
setModerationTarget(newValue.id);
} else {
setModerationTarget('');
}
}}
onInputChange={(event, newInputValue) => {
// Allow manual input of user IDs
if (event && event.type === 'change') {
setModerationTarget(newInputValue);
}
}}
inputValue={moderationTarget}
freeSolo
renderInput={(params) => (
<TextField
{...params}
label="User"
placeholder="Select user or enter user ID"
fullWidth
/>
)}
renderOption={(props, option) => (
<Box component="li" {...props}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{option.globalName || option.username}
</Typography>
<Typography variant="caption" color="text.secondary">
@{option.username} ID: {option.id}
</Typography>
</Box>
</Box>
)}
filterOptions={(options, { inputValue }) => {
if (!inputValue) return options.slice(0, 50); // Limit to 50 for performance
const filtered = options.filter(option =>
option.username.toLowerCase().includes(inputValue.toLowerCase()) ||
(option.globalName && option.globalName.toLowerCase().includes(inputValue.toLowerCase())) ||
option.displayName.toLowerCase().includes(inputValue.toLowerCase()) ||
option.id.includes(inputValue)
);
return filtered.slice(0, 50); // Limit results
}}
/>
<TextField
label="Reason (minimum 3 words)"
value={moderationReason}
onChange={(e) => setModerationReason(e.target.value)}
placeholder="Enter reason for moderation action"
fullWidth
multiline
rows={2}
/>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
variant="contained"
color="warning"
onClick={() => handleModerationAction('kick')}
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim()}
>
Kick User
</Button>
<Button
variant="contained"
color="error"
onClick={() => handleModerationAction('ban')}
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim()}
>
Ban User
</Button>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
label="Duration (minutes)"
type="number"
value={timeoutDuration}
onChange={(e) => setTimeoutDuration(e.target.value)}
sx={{ width: 150 }}
inputProps={{ min: 1, max: 40320 }}
/>
<Button
variant="contained"
color="secondary"
onClick={() => handleModerationAction('timeout')}
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim() || !timeoutDuration}
>
Timeout User
</Button>
</Box>
</Box>
</Box>
</Box>
</AccordionDetails>
</Accordion>
<ConfirmDialog
@@ -936,7 +1397,28 @@ const ServerSettings = () => {
title="Delete Kick User"
message={`Are you sure you want to remove ${pendingKickUser || ''} from the watch list?`}
/>
</div>
{/* Confirm dialog for deleting individual admin log */}
<ConfirmDialog
open={deleteLogDialog.open}
onClose={() => setDeleteLogDialog({ open: false, logId: null, logAction: '' })}
onConfirm={() => handleDeleteLog(deleteLogDialog.logId)}
title="Delete Admin Log"
message={`Are you sure you want to delete this ${deleteLogDialog.logAction} log? This action cannot be undone.`}
/>
{/* Confirm dialog for deleting all admin logs */}
<ConfirmDialog
open={deleteAllLogsDialog}
onClose={() => setDeleteAllLogsDialog(false)}
onConfirm={handleDeleteAllLogs}
title="Delete All Admin Logs"
message="Are you sure you want to delete all admin logs? This action cannot be undone."
/>
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={() => setSnackbarOpen(false)}>
<Alert onClose={() => setSnackbarOpen(false)} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
</Box>
);
};

View File

@@ -60,6 +60,9 @@ export function BackendProvider({ children }) {
es.addEventListener('commandToggle', forward('commandToggle'));
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
es.addEventListener('adminLogAdded', forward('adminLogAdded'));
es.addEventListener('adminLogDeleted', forward('adminLogDeleted'));
es.addEventListener('adminLogsCleared', forward('adminLogsCleared'));
es.onerror = () => {
// Let consumers react to backendOnline state changes instead of surfacing connection errors

View File

@@ -11,3 +11,28 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Global responsive styles */
#root {
max-width: 100vw;
overflow-x: hidden;
}
/* Ensure content doesn't overflow on ultra-wide screens */
* {
box-sizing: border-box;
}
/* Responsive typography adjustments */
@media (max-width: 600px) {
body {
font-size: 14px;
}
}
@media (min-width: 1200px) {
/* Ultra-wide screen adjustments */
.MuiContainer-root {
max-width: 1200px !important;
}
}

View File

@@ -3,6 +3,13 @@ import { createTheme } from '@mui/material/styles';
export const lightTheme = createTheme({
palette: {
mode: 'light',
background: {
default: '#e8e8e8', // More greyish background, less bright white
paper: '#ffffff',
},
primary: {
main: '#1565c0', // Slightly darker blue for less brightness
},
},
});