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
|
// Simple Server-Sent Events (SSE) broadcaster
|
||||||
const sseClients = new Map(); // key: guildId or '*' -> array of res
|
const sseClients = new Map(); // key: guildId or '*' -> array of res
|
||||||
function publishEvent(guildId, type, payload) {
|
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
|
// send to guild-specific subscribers
|
||||||
const list = sseClients.get(guildId) || [];
|
const list = sseClients.get(guildId) || [];
|
||||||
for (const res of list.slice()) {
|
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) => {
|
app.get('/api/servers/:guildId/settings', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
try {
|
try {
|
||||||
@@ -612,6 +638,21 @@ app.get('/', (req, res) => {
|
|||||||
res.send('Hello from the backend!');
|
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
|
// Return list of bot commands and per-guild enabled/disabled status
|
||||||
app.get('/api/servers/:guildId/commands', async (req, res) => {
|
app.get('/api/servers/:guildId/commands', async (req, res) => {
|
||||||
try {
|
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] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard')
|
||||||
- [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar
|
- [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,36 +1,62 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { UserProvider } from './contexts/UserContext';
|
import { UserProvider } from './contexts/UserContext';
|
||||||
|
import { BackendProvider, useBackend } from './contexts/BackendContext';
|
||||||
import { CssBaseline } from '@mui/material';
|
import { CssBaseline } from '@mui/material';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
import ServerSettings from './components/ServerSettings';
|
import ServerSettings from './components/server/ServerSettings';
|
||||||
import NavBar from './components/NavBar';
|
import NavBar from './components/NavBar';
|
||||||
import HelpPage from './components/HelpPage';
|
import HelpPage from './components/server/HelpPage';
|
||||||
import DiscordPage from './components/DiscordPage';
|
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 (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Router>
|
<Router>
|
||||||
<TitleSetter />
|
<TitleSetter />
|
||||||
<NavBar />
|
{!backendOnline ? (
|
||||||
<Routes>
|
<MaintenancePage onRetry={handleRetry} checking={checking} />
|
||||||
<Route path="/" element={<Login />} />
|
) : (
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<>
|
||||||
<Route path="/server/:guildId" element={<ServerSettings />} />
|
<NavBar />
|
||||||
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
<Routes>
|
||||||
<Route path="/discord" element={<DiscordPage />} />
|
<Route path="/" element={<Login />} />
|
||||||
</Routes>
|
<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>
|
</Router>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<UserProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<BackendProvider>
|
||||||
|
<AppInner />
|
||||||
|
</BackendProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</UserProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
// small helper component to set the browser tab title based on current route
|
// 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 { UserContext } from '../contexts/UserContext';
|
||||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||||
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
|
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 Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -78,7 +78,7 @@ const Dashboard = () => {
|
|||||||
const statuses = {};
|
const statuses = {};
|
||||||
await Promise.all(guilds.map(async (g) => {
|
await Promise.all(guilds.map(async (g) => {
|
||||||
try {
|
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;
|
statuses[g.id] = resp.data.isBotInServer;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statuses[g.id] = false;
|
statuses[g.id] = false;
|
||||||
@@ -99,7 +99,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const handleInviteBot = (e, guild) => {
|
const handleInviteBot = (e, guild) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
axios.get(`${API_BASE}/api/client-id`).then(resp => {
|
get('/api/client-id').then(resp => {
|
||||||
const clientId = resp.data.clientId;
|
const clientId = resp.data.clientId;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
setSnackbarMessage('No client ID available');
|
setSnackbarMessage('No client ID available');
|
||||||
@@ -124,7 +124,7 @@ const Dashboard = () => {
|
|||||||
const handleConfirmLeave = async () => {
|
const handleConfirmLeave = async () => {
|
||||||
if (!selectedGuild) return;
|
if (!selectedGuild) return;
|
||||||
try {
|
try {
|
||||||
await axios.post(`${API_BASE}/api/servers/${selectedGuild.id}/leave`);
|
await post(`/api/servers/${selectedGuild.id}/leave`);
|
||||||
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
|
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
|
||||||
setSnackbarMessage('Bot left the server');
|
setSnackbarMessage('Bot left the server');
|
||||||
setSnackbarOpen(true);
|
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 React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import { get } from '../../lib/api';
|
||||||
import { Box, IconButton, Typography } from '@mui/material';
|
import { Box, IconButton, Typography } from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
|
||||||
@@ -10,9 +10,7 @@ const HelpPage = () => {
|
|||||||
const [commands, setCommands] = useState([]);
|
const [commands, setCommands] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
get(`/api/servers/${guildId}/commands`).then(res => {
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
|
|
||||||
.then(res => {
|
|
||||||
const cmds = res.data || [];
|
const cmds = res.data || [];
|
||||||
// sort: locked commands first (preserve relative order), then others alphabetically
|
// sort: locked commands first (preserve relative order), then others alphabetically
|
||||||
const locked = cmds.filter(c => c.locked);
|
const locked = cmds.filter(c => c.locked);
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useBackend } from '../../contexts/BackendContext';
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
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, Snackbar, Alert } from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
// UserSettings moved to NavBar
|
// UserSettings moved to NavBar
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
|
||||||
@@ -80,51 +81,48 @@ const ServerSettings = () => {
|
|||||||
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
|
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
|
||||||
|
|
||||||
// Check if bot is in server
|
// Check if bot is in server
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`).then(response => {
|
||||||
.then(response => {
|
setIsBotInServer(response.data.isBotInServer);
|
||||||
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
|
// Fetch client ID
|
||||||
axios.get(`${API_BASE}/api/client-id`)
|
axios.get(`${API_BASE}/api/client-id`).then(response => {
|
||||||
.then(response => {
|
setClientId(response.data.clientId);
|
||||||
setClientId(response.data.clientId);
|
}).catch(() => {
|
||||||
});
|
// ignore when offline
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch channels
|
// Fetch channels
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/channels`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
|
||||||
.then(response => {
|
setChannels(response.data);
|
||||||
setChannels(response.data);
|
}).catch(() => {
|
||||||
});
|
setChannels([]);
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch welcome/leave settings
|
// Fetch welcome/leave settings
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
|
||||||
.then(response => {
|
if (response.data) setWelcomeLeaveSettings(response.data);
|
||||||
if (response.data) {
|
}).catch(() => {
|
||||||
setWelcomeLeaveSettings(response.data);
|
// ignore
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch roles
|
// Fetch roles
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/roles`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/roles`).then(response => {
|
||||||
.then(response => {
|
setRoles(response.data);
|
||||||
setRoles(response.data);
|
}).catch(() => setRoles([]));
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch autorole settings
|
// Fetch autorole settings
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`).then(response => {
|
||||||
.then(response => {
|
if (response.data) setAutoroleSettings(response.data);
|
||||||
if (response.data) {
|
}).catch(() => {});
|
||||||
setAutoroleSettings(response.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch commands/help list
|
// Fetch commands/help list
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(response => {
|
||||||
.then(response => {
|
setCommandsList(response.data || []);
|
||||||
setCommandsList(response.data || []);
|
}).catch(() => setCommandsList([]));
|
||||||
})
|
|
||||||
.catch(() => setCommandsList([]));
|
|
||||||
|
|
||||||
// Fetch invites
|
// Fetch invites
|
||||||
// Fetch live notifications settings and watched users
|
// Fetch live notifications settings and watched users
|
||||||
@@ -134,10 +132,10 @@ const ServerSettings = () => {
|
|||||||
setLiveChannelId(s.channelId || '');
|
setLiveChannelId(s.channelId || '');
|
||||||
setLiveTwitchUser(s.twitchUser || '');
|
setLiveTwitchUser(s.twitchUser || '');
|
||||||
}).catch(() => {});
|
}).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`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
|
||||||
.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
|
// Open commands accordion if navigated from Help back button
|
||||||
if (location.state && location.state.openCommands) {
|
if (location.state && location.state.openCommands) {
|
||||||
@@ -146,97 +144,136 @@ const ServerSettings = () => {
|
|||||||
|
|
||||||
}, [guildId, location.state]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!guildId) return;
|
if (!eventTarget) return;
|
||||||
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
|
|
||||||
const url = `${API_BASE}/api/events?guildId=${encodeURIComponent(guildId)}`;
|
const onTwitchUsers = (e) => {
|
||||||
let es = null;
|
const data = e.detail || {};
|
||||||
try {
|
// payload is { users: [...], guildId }
|
||||||
es = new EventSource(url);
|
if (!data) return;
|
||||||
} catch (err) {
|
if (data.guildId && data.guildId !== guildId) return; // ignore other guilds
|
||||||
console.warn('EventSource not available or failed to connect', err);
|
setWatchedUsers(data.users || []);
|
||||||
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
|
|
||||||
};
|
};
|
||||||
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 = () => {
|
const handleCloseSnackbar = () => {
|
||||||
setSnackbarOpen(false);
|
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) => {
|
const handleAutoroleSettingUpdate = (newSettings) => {
|
||||||
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
|
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setAutoroleSettings(newSettings);
|
setAutoroleSettings(newSettings);
|
||||||
}
|
}
|
||||||
});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoroleToggleChange = (event) => {
|
const handleAutoroleToggleChange = (event) => {
|
||||||
@@ -255,7 +292,7 @@ const ServerSettings = () => {
|
|||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setWelcomeLeaveSettings(newSettings);
|
setWelcomeLeaveSettings(newSettings);
|
||||||
}
|
}
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleChange = (type) => (event) => {
|
const handleToggleChange = (type) => (event) => {
|
||||||
@@ -320,15 +357,13 @@ const ServerSettings = () => {
|
|||||||
await axios.post(`${API_BASE}/api/servers/${guildId}/leave`);
|
await axios.post(`${API_BASE}/api/servers/${guildId}/leave`);
|
||||||
setIsBotInServer(false);
|
setIsBotInServer(false);
|
||||||
} catch (error) {
|
} 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);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
navigate('/dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px' }}>
|
<div style={{ padding: '20px' }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
@@ -474,7 +509,8 @@ const ServerSettings = () => {
|
|||||||
setInvites(prev => [...prev, resp.data.invite]);
|
setInvites(prev => [...prev, resp.data.invite]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating invite:', err);
|
setSnackbarMessage('Failed to create invite. Backend may be offline.');
|
||||||
|
setSnackbarOpen(true);
|
||||||
}
|
}
|
||||||
}} disabled={!isBotInServer}>Create Invite</Button>
|
}} disabled={!isBotInServer}>Create Invite</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -499,6 +535,7 @@ const ServerSettings = () => {
|
|||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.value = inv.url;
|
input.value = inv.url;
|
||||||
document.body.appendChild(input);
|
document.body.appendChild(input);
|
||||||
|
document.body.appendChild(input);
|
||||||
input.select();
|
input.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
document.body.removeChild(input);
|
document.body.removeChild(input);
|
||||||
@@ -506,7 +543,6 @@ const ServerSettings = () => {
|
|||||||
setSnackbarMessage('Copied invite URL to clipboard');
|
setSnackbarMessage('Copied invite URL to clipboard');
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clipboard copy failed:', err);
|
|
||||||
setSnackbarMessage('Failed to copy — please copy manually');
|
setSnackbarMessage('Failed to copy — please copy manually');
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
}
|
}
|
||||||
@@ -636,7 +672,7 @@ const ServerSettings = () => {
|
|||||||
await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
|
await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
|
||||||
setWatchedUsers(prev => [...prev.filter(u => u !== liveTwitchUser.toLowerCase()), liveTwitchUser.toLowerCase()]);
|
setWatchedUsers(prev => [...prev.filter(u => u !== liveTwitchUser.toLowerCase()), liveTwitchUser.toLowerCase()]);
|
||||||
setLiveTwitchUser('');
|
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>
|
}} disabled={!isBotInServer}>Add</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -663,7 +699,7 @@ const ServerSettings = () => {
|
|||||||
<Button variant="contained" onClick={async () => {
|
<Button variant="contained" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId });
|
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>
|
}} disabled={!isBotInServer}>Save</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -743,7 +779,6 @@ const ServerSettings = () => {
|
|||||||
setSnackbarMessage('Invite deleted');
|
setSnackbarMessage('Invite deleted');
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
} catch (err) {
|
} 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';
|
const msg = (err && err.message) || (err && err.response && err.response.data && err.response.data.message) || 'Failed to delete invite';
|
||||||
setSnackbarMessage(msg);
|
setSnackbarMessage(msg);
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
@@ -772,7 +807,6 @@ const ServerSettings = () => {
|
|||||||
setSnackbarMessage('Twitch user removed');
|
setSnackbarMessage('Twitch user removed');
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete twitch user', err);
|
|
||||||
setSnackbarMessage('Failed to delete twitch user');
|
setSnackbarMessage('Failed to delete twitch user');
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
} finally {
|
} 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 { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
|
||||||
import { lightTheme, darkTheme, discordTheme } from '../themes';
|
import { lightTheme, darkTheme, discordTheme } from '../themes';
|
||||||
import { UserContext } from './UserContext';
|
import { UserContext } from './UserContext';
|
||||||
import axios from 'axios';
|
import { post } from '../lib/api';
|
||||||
|
|
||||||
export const ThemeContext = createContext();
|
export const ThemeContext = createContext();
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
|
|
||||||
const changeTheme = (name) => {
|
const changeTheme = (name) => {
|
||||||
if (user) {
|
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);
|
localStorage.setItem('themeName', name);
|
||||||
setThemeName(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