Compare commits
2 Commits
2ae7202445
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 70979cdd27 | |||
| ff10bb3183 |
407
README.md
407
README.md
@@ -1,320 +1,161 @@
|
|||||||
# ECS Full Stack
|
# ECS Full Stack
|
||||||
|
|
||||||
A full-stack example project that integrates a React frontend, an Express backend, and a Discord bot. The app provides a dashboard for server admins to manage bot settings and invites, plus Discord moderation/integration features via a bot running with discord.js.
|
A full-stack Discord bot management dashboard with React frontend, Express backend, and Discord.js bot integration. Server admins can manage bot settings, invites, moderation, and live notifications through a modern web interface.
|
||||||
|
|
||||||
This README documents how to get the project running, what environment variables are required, where to get Discord keys, how the invite token flow works, and basic troubleshooting tips.
|
## Features
|
||||||
|
|
||||||
Note: The backend has been updated to support Postgres persistence (see `CHANGELOG.md`). The backend now requires `DATABASE_URL` to run in the default configuration; if you prefer the legacy encrypted file store, see the notes under "Developer notes".
|
- **Dashboard**: View Discord servers and manage per-server settings
|
||||||
|
- **Invite Management**: Create, list, and revoke server invites with custom options
|
||||||
|
- **Moderation**: Direct ban/kick/timeout actions from web interface with user autocomplete
|
||||||
|
- **Live Notifications**: Twitch stream notifications with rich embeds
|
||||||
|
- **Admin Logs**: Complete moderation action logging with real-time updates
|
||||||
|
- **Theme Support**: Light, dark, and Discord-themed UI options
|
||||||
|
|
||||||
## Repository layout
|
## Quick Start
|
||||||
|
|
||||||
- `frontend/` — React (Create React App) frontend. Uses `REACT_APP_API_BASE` to communicate with the backend in dev and production.
|
### Prerequisites
|
||||||
- `backend/` — Express backend and API server that also coordinates with the `discord-bot` library to manage guilds, invites, and settings. Uses environment variables for configuration.
|
- Node.js 18+
|
||||||
- `discord-bot/` — small wrapper that logs the bot in and exposes the discord.js client used by the backend.
|
- PostgreSQL database
|
||||||
- `checklist.md`, `README.md`, other docs and small scripts at repo root.
|
- Discord application with bot user
|
||||||
|
|
||||||
## What this project does
|
### Setup
|
||||||
|
|
||||||
- Provides a React dashboard where a user can view servers the bot is connected to and manage per-server settings (welcome/leave messages, autorole, toggling commands, invite creation/listing/deletion).
|
1. **Clone and install dependencies:**
|
||||||
- Runs a Discord bot (discord.js) that performs moderation and server features. The backend and bot are closely integrated: the backend hosts the API and the bot client is shared to fetch guild data and manipulate invites/channels/roles.
|
```bash
|
||||||
- Uses a short-lived token flow to authorize invite deletions from the frontend without embedding long-lived secrets in the client.
|
git clone <repository-url>
|
||||||
|
cd ECS-FullStack
|
||||||
|
npm install # Run in both frontend/ and backend/ directories
|
||||||
|
```
|
||||||
|
|
||||||
Expanded: what this app does
|
2. **Configure Discord App:**
|
||||||
|
- Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
- Create new application and bot user
|
||||||
|
- Copy Client ID, Client Secret, and Bot Token
|
||||||
|
|
||||||
- Hosts a dashboard (React) that lists Discord guilds where the bot is present and lets server admins:
|
3. **Database Setup:**
|
||||||
- create and manage invites (create invites with options, view persisted invites, copy and revoke)
|
```sql
|
||||||
- configure Welcome and Leave messages and channels
|
CREATE DATABASE ecs_fullstack;
|
||||||
- enable/disable bot commands per server
|
CREATE USER ecs_user WITH PASSWORD 'your_password';
|
||||||
- set autorole behavior for new members
|
GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user;
|
||||||
- Provides a backend API (Express) that coordinates with a discord.js bot to perform live guild operations (fetch channels/roles, create invites, leave guilds)
|
```
|
||||||
- Stores configuration and invites in Postgres (recommended) or a legacy encrypted `db.json`
|
|
||||||
|
|
||||||
## Quickstart — prerequisites
|
4. **Environment Configuration:**
|
||||||
|
|
||||||
- Node.js (recommended 18.x or later) and npm
|
**backend/.env:**
|
||||||
- A Discord application with a Bot user (to get `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`) — see below for setup steps
|
```env
|
||||||
- Optional: a VPS or Tailscale IP if you want to run the frontend/backend on a non-localhost address
|
DATABASE_URL=postgres://ecs_user:password@localhost:5432/ecs_fullstack
|
||||||
|
DISCORD_CLIENT_ID=your_client_id
|
||||||
|
DISCORD_CLIENT_SECRET=your_client_secret
|
||||||
|
DISCORD_BOT_TOKEN=your_bot_token
|
||||||
|
ENCRYPTION_KEY=your_32_byte_secret
|
||||||
|
BACKEND_BASE=http://localhost:3002
|
||||||
|
FRONTEND_BASE=http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
## Environment configuration (.env)
|
**frontend/.env:**
|
||||||
|
```env
|
||||||
|
REACT_APP_API_BASE=http://localhost:3002
|
||||||
|
```
|
||||||
|
|
||||||
There are env files used by the backend and frontend. Create `.env` files in the `backend/` and `frontend/` folders for local development. Examples follow.
|
5. **Start the application:**
|
||||||
|
```bash
|
||||||
|
# Backend (includes Discord bot)
|
||||||
|
cd backend && npm start
|
||||||
|
|
||||||
### backend/.env (example)
|
# Frontend (separate terminal)
|
||||||
|
cd frontend && npm start
|
||||||
|
```
|
||||||
|
|
||||||
PORT=3002
|
6. **Invite Bot to Server:**
|
||||||
HOST=0.0.0.0
|
- Use OAuth2 URL Generator in Discord Developer Portal
|
||||||
BACKEND_BASE=http://your-server-or-ip:3002
|
- Select `bot` and `applications.commands` scopes
|
||||||
FRONTEND_BASE=http://your-server-or-ip:3001
|
- Choose appropriate permissions
|
||||||
CORS_ORIGIN=http://your-server-or-ip:3001
|
- Visit generated URL to invite bot
|
||||||
DISCORD_CLIENT_ID=your_discord_client_id
|
|
||||||
DISCORD_CLIENT_SECRET=your_discord_client_secret
|
|
||||||
ENCRYPTION_KEY=a-32-byte-or-longer-secret
|
|
||||||
INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens
|
|
||||||
|
|
||||||
# Postgres example (optional but recommended)
|
## Project Structure
|
||||||
# DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
|
|
||||||
|
|
||||||
- `PORT` / `HOST`: where the backend listens.
|
```
|
||||||
- `BACKEND_BASE` and `FRONTEND_BASE`: used for constructing OAuth redirect URIs and links.
|
ECS-FullStack/
|
||||||
- `CORS_ORIGIN`: optional; set to your frontend origin to restrict CORS.
|
├── frontend/ # React dashboard
|
||||||
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET`: from the Discord Developer Portal (see below).
|
├── backend/ # Express API + Discord bot
|
||||||
- `ENCRYPTION_KEY` or `INVITE_TOKEN_SECRET`: used to sign short-lived invite tokens. Keep this secret.
|
├── discord-bot/ # Bot wrapper
|
||||||
|
├── checklist.md # Feature tracking
|
||||||
Note: This project previously supported an `INVITE_API_KEY` static secret; that requirement has been removed. Invite deletes are authorized via short-lived invite tokens by default.
|
└── README.md
|
||||||
|
|
||||||
### Twitch Live Notifications (optional)
|
|
||||||
|
|
||||||
This project can detect when watched Twitch users go live and post notifications to a configured Discord channel for each guild. To enable this feature, add the following to `backend/.env`:
|
|
||||||
|
|
||||||
- `TWITCH_CLIENT_ID` — your Twitch app client id
|
|
||||||
- `TWITCH_CLIENT_SECRET` — your Twitch app client secret
|
|
||||||
- `TWITCH_POLL_INTERVAL_MS` — optional, poll interval in milliseconds (default 30000)
|
|
||||||
|
|
||||||
When configured, the backend exposes:
|
|
||||||
|
|
||||||
- GET /api/twitch/streams?users=user1,user2 — returns stream info for the listed usernames (used by the frontend and bot watcher)
|
|
||||||
|
|
||||||
The bot includes a watcher that polls watched usernames per-guild and posts a message to the configured channel when a streamer goes live. The message includes the stream title and a link to the Twitch stream.
|
|
||||||
|
|
||||||
If you run the backend and the bot on separate hosts, you can configure the backend to push setting updates to the bot so toggles and watched users propagate immediately:
|
|
||||||
|
|
||||||
- `BOT_PUSH_URL` — the URL the bot will expose for the backend to POST setting updates to (e.g., http://bot-host:4002)
|
|
||||||
- `BOT_SECRET` — a shared secret used by the backend and bot to secure push requests
|
|
||||||
- `BOT_PUSH_PORT` — optional, the port the bot listens on for push requests (if set the bot starts a small HTTP receiver)
|
|
||||||
|
|
||||||
|
|
||||||
### frontend/.env (example)
|
|
||||||
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=3001
|
|
||||||
REACT_APP_API_BASE=http://your-server-or-ip:3002
|
|
||||||
|
|
||||||
Set `REACT_APP_API_BASE` to point at the backend so the frontend can call API endpoints.
|
|
||||||
|
|
||||||
## Create a Discord Application and Bot (short)
|
|
||||||
|
|
||||||
1. Go to the Discord Developer Portal: https://discord.com/developers/applications
|
|
||||||
2. Create a new application.
|
|
||||||
3. Under "OAuth2" -> "General", add your redirect URI:
|
|
||||||
- For dev: `http://your-server-or-ip:3002/auth/discord/callback`
|
|
||||||
- Make sure `BACKEND_BASE` matches the host/port you set in `backend/.env`.
|
|
||||||
4. Under "Bot" create a Bot user and copy the Bot token (NOT committed to source).
|
|
||||||
5. Under "OAuth2" -> "URL Generator" select scopes `bot` and `applications.commands` and select permissions (e.g., Administrator if you want full access for testing). Use the generated URL to invite the bot to your guild during testing.
|
|
||||||
|
|
||||||
Store the Client ID / Client Secret in your `backend/.env` as `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`.
|
|
||||||
|
|
||||||
## Invite token flow (why and how)
|
|
||||||
|
|
||||||
- To avoid embedding long-lived secrets in a web client, invite deletions are authorized with a short-lived HMAC-signed token.
|
|
||||||
- The frontend requests a token with:
|
|
||||||
GET /api/servers/:guildId/invite-token
|
|
||||||
- The backend returns `{ token: '...' }`. The frontend then calls
|
|
||||||
DELETE /api/servers/:guildId/invites/:code
|
|
||||||
with header `x-invite-token: <token>`
|
|
||||||
- Token TTL is short (default 5 minutes) and is signed using `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` from backend `.env`.
|
|
||||||
|
|
||||||
Security note: Currently the `/invite-token` endpoint issues tokens to any caller. For production you should restrict this endpoint by requiring OAuth authentication and checking that the requesting user is authorized for the target guild.
|
|
||||||
|
|
||||||
## Run the app locally
|
|
||||||
|
|
||||||
1. Backend
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
# create backend/.env from the example above
|
|
||||||
npm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional: using Postgres (recommended)
|
## API Endpoints
|
||||||
|
|
||||||
1. Create a Postgres database and user (pgAdmin or psql)
|
### Server Management
|
||||||
2. Set `DATABASE_URL` in `backend/.env`, e.g.:
|
- `GET /api/servers/:guildId` - Server info and settings
|
||||||
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
|
- `GET /api/servers/:guildId/members` - Server member list
|
||||||
3. Start the backend; on startup the backend will create simple tables if missing.
|
- `GET /api/servers/:guildId/channels` - Text channels
|
||||||
|
- `GET /api/servers/:guildId/roles` - Server roles
|
||||||
|
|
||||||
Migration note:
|
### Invites
|
||||||
- If you have existing data in `backend/db.json`, a migration script is planned to import invites and server settings into Postgres. I can add that script on request.
|
- `GET /api/servers/:guildId/invites` - List invites
|
||||||
|
- `POST /api/servers/:guildId/invites` - Create invite
|
||||||
|
- `DELETE /api/servers/:guildId/invites/:code` - Delete invite
|
||||||
|
|
||||||
2. Frontend
|
### Moderation
|
||||||
|
- `POST /api/servers/:guildId/moderate` - Ban/kick/timeout users
|
||||||
|
- `GET /api/servers/:guildId/admin-logs` - View moderation logs
|
||||||
|
|
||||||
```powershell
|
### Live Notifications
|
||||||
cd frontend
|
- `GET/POST /api/servers/:guildId/live-notifications` - Settings
|
||||||
npm install
|
- `GET/POST /api/servers/:guildId/twitch-users` - Watched users
|
||||||
# create frontend/.env with REACT_APP_API_BASE pointing to the backend
|
|
||||||
npm run start
|
## Environment Variables
|
||||||
|
|
||||||
|
### Required
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
|
- `DISCORD_CLIENT_ID` - Discord app client ID
|
||||||
|
- `DISCORD_CLIENT_SECRET` - Discord app client secret
|
||||||
|
- `DISCORD_BOT_TOKEN` - Bot token
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
- `TWITCH_CLIENT_ID` - Twitch app client ID
|
||||||
|
- `TWITCH_CLIENT_SECRET` - Twitch app client secret
|
||||||
|
- `BOT_PUSH_URL` - For separate bot/backend deployment
|
||||||
|
- `CORS_ORIGIN` - Restrict API access
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
cd frontend && npm test
|
||||||
|
cd backend && npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Discord bot
|
### Building for Production
|
||||||
|
```bash
|
||||||
- The backend boots the bot client (see `discord-bot/`), so if the backend is started and credentials are correct, the bot will log in and register slash commands. You can also run the `discord-bot` project separately if you prefer.
|
cd frontend && npm run build
|
||||||
|
cd backend && npm run build # If applicable
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- Backend refuses to start or missing package.json: ensure you run `npm install` in the `backend` folder and run `npm start` from that folder.
|
### Common Issues
|
||||||
- If the backend exits with "DATABASE_URL is not set": either set `DATABASE_URL` in `backend/.env` pointing to your Postgres DB, or restore the legacy behavior by editing `backend/index.js` to re-enable the encrypted `db.json` fallback (not recommended for production).
|
- **Database connection failed**: Verify `DATABASE_URL` format and credentials
|
||||||
- CORS errors: verify `CORS_ORIGIN` and `REACT_APP_API_BASE` match your frontend origin.
|
- **CORS errors**: Check `CORS_ORIGIN` matches your frontend URL
|
||||||
- Invite delete unauthorized: ensure backend `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` is present and token TTL has not expired. Check the backend logs for validation details.
|
- **Bot not responding**: Ensure bot has proper permissions in server
|
||||||
- Token issues: clock skew can cause tokens to appear expired — ensure server and client clocks are reasonably in sync.
|
- **Invite deletion fails**: Check `ENCRYPTION_KEY` is set
|
||||||
|
|
||||||
## Developer notes
|
### Logs
|
||||||
|
- Backend logs Discord bot status and API requests
|
||||||
|
- Frontend console shows API calls and errors
|
||||||
|
- Check browser Network tab for failed requests
|
||||||
|
|
||||||
- The dashboard UI is in `frontend/src/components/` (notable files: `Dashboard.js`, `ServerSettings.js`, `Login.js`).
|
## Contributing
|
||||||
- The Express API is in `backend/index.js` and uses `discord-bot` (discord.js client) to operate on guilds, invites, channels and roles.
|
|
||||||
- Invite delete flow: frontend fetches a short-lived token then requests DELETE with header `x-invite-token`.
|
|
||||||
|
|
||||||
## Next steps / suggestions
|
1. Fork the repository
|
||||||
|
2. Create feature branch
|
||||||
|
3. Make changes with tests
|
||||||
|
4. Submit pull request
|
||||||
|
|
||||||
- Harden `/api/servers/:guildId/invite-token` to require an authenticated user and verify the user has admin permissions for the guild.
|
## License
|
||||||
- Add rate-limiting to token issuance and optionally keep the old `INVITE_API_KEY` option for server-to-server automation.
|
|
||||||
- Updated docs: the README and CHANGELOG were updated to reflect Postgres integration and recent frontend/backend changes. See `CHANGELOG.md` and `checklist.md` for details.
|
|
||||||
|
|
||||||
If you want, I can add step-by-step instructions to create the `.env` files from templates, or implement the production safe option of authenticating `/invite-token` requests. Tell me which you'd prefer.
|
MIT License - see LICENSE file for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
Updated: Oct 4, 2025
|
|
||||||
|
|
||||||
## Full setup guide (detailed)
|
**Updated**: October 9, 2025
|
||||||
|
|
||||||
This section walks through the exact steps to get the project running locally or on a machine reachable via Tailscale/Nginx Proxy Manager.
|
|
||||||
|
|
||||||
Prerequisites
|
|
||||||
1. Node.js 18+ and npm
|
|
||||||
2. Postgres (local or remote) or use an existing Postgres server reachable over your network/Tailscale
|
|
||||||
3. Discord application + Bot credentials and (optional) Twitch app credentials
|
|
||||||
|
|
||||||
Database (Postgres) setup
|
|
||||||
1. Create a Postgres database and user. Example psql commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u postgres psql
|
|
||||||
CREATE DATABASE ecs_fullstack;
|
|
||||||
CREATE USER ecs_user WITH PASSWORD 'supersecret';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user;
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Set the `DATABASE_URL` in `backend/.env`:
|
|
||||||
|
|
||||||
```
|
|
||||||
DATABASE_URL=postgres://ecs_user:supersecret@127.0.0.1:5432/ecs_fullstack
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start the backend (it will run migrations / ensure tables at startup):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend configuration (.env)
|
|
||||||
- `DATABASE_URL` - required for Postgres persistence
|
|
||||||
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN` - from Discord Developer Portal
|
|
||||||
- `FRONTEND_BASE` - public frontend URL (used for OAuth redirect)
|
|
||||||
- `PORT`, `HOST` - where backend listens
|
|
||||||
- `CORS_ORIGIN` - optional restrict origin to your frontend URL
|
|
||||||
- `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` - optional for Twitch integration
|
|
||||||
|
|
||||||
Frontend configuration
|
|
||||||
1. In `frontend/.env` set:
|
|
||||||
|
|
||||||
```
|
|
||||||
REACT_APP_API_BASE=https://your-domain-or-ip:3002
|
|
||||||
```
|
|
||||||
|
|
||||||
2. For development behind an HTTPS domain (Nginx Proxy Manager), ensure the CRA dev client uses `wss` by setting the `WDS_SOCKET_*` variables in `frontend/.env` (see docs if using a TLS domain)
|
|
||||||
|
|
||||||
Start the frontend dev server:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Bot behaviour and deployment
|
|
||||||
- The `backend` process will boot the Discord bot client when valid `DISCORD_BOT_TOKEN` is present. The bot registers slash commands per guild on startup and responds to backend pushes for setting updates.
|
|
||||||
- If you prefer to run the bot separately, you can run the `discord-bot` module separately; ensure `BOT_PUSH_URL`/`BOT_SECRET` are configured if backend and bot are on different hosts.
|
|
||||||
|
|
||||||
Useful endpoints
|
|
||||||
- `GET /api/servers/:guildId/commands` — returns the authoritative list of commands and per-guild enabled/locked status.
|
|
||||||
- `GET/POST /api/servers/:guildId/live-notifications` — get/update live notification settings
|
|
||||||
- `GET /api/twitch/streams?users=user1,user2` — proxy to twitch helix for streams (backend caches app-token)
|
|
||||||
- `GET /api/events?guildId=...` — Server-Sent Events for real-time updates (ServerSettings subscribes to this)
|
|
||||||
|
|
||||||
### Twitch Live Notification Settings (Detailed)
|
|
||||||
|
|
||||||
Endpoint: `GET/POST /api/servers/:guildId/live-notifications`
|
|
||||||
|
|
||||||
Shape returned by GET:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"channelId": "123456789012345678",
|
|
||||||
"twitchUser": "deprecated-single-user-field",
|
|
||||||
"message": "🔴 {user} is now live!",
|
|
||||||
"customMessage": "Custom promo text with link etc"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `twitchUser` is a legacy single-user field retained for backward compatibility. The active watched users list lives under `/api/servers/:guildId/twitch-users`.
|
|
||||||
- `message` (default message) and `customMessage` (override) are persisted. If `customMessage` is non-empty it is used when announcing a live stream; otherwise `message` is used. If both are empty the bot falls back to `🔴 {user} is now live!`.
|
|
||||||
- Update by POSTing the same shape (omit fields you don't change is okay; unspecified become empty unless preserved on server).
|
|
||||||
|
|
||||||
### Discord Bot Twitch Embed Layout
|
|
||||||
|
|
||||||
When a watched streamer goes live the bot posts a standardized embed. The layout is fixed to keep consistency:
|
|
||||||
|
|
||||||
Embed fields:
|
|
||||||
1. Title: Stream title (hyperlinked to Twitch URL) or fallback "{user} is live".
|
|
||||||
2. Author: Twitch display name with avatar and link.
|
|
||||||
3. Thumbnail: Stream thumbnail (or profile image fallback).
|
|
||||||
4. Fields:
|
|
||||||
- Category: Game / category name (or "Unknown").
|
|
||||||
- Viewers: Current viewer count.
|
|
||||||
5. Description: Twitch user bio (if available via Helix `users` endpoint) else truncated stream description (200 chars).
|
|
||||||
6. Footer: `ehchadservices • Started: <localized start time>`.
|
|
||||||
|
|
||||||
Pre-Embed Message (optional):
|
|
||||||
- If `customMessage` is set it is posted as normal message content above the embed.
|
|
||||||
- Else if `message` is set it is posted above the embed.
|
|
||||||
- Else no prefix content is posted (embed alone).
|
|
||||||
|
|
||||||
Variables:
|
|
||||||
- `{user}` in messages will not be auto-replaced server-side yet; include the username manually if desired. (Can add template replacement in a future iteration.)
|
|
||||||
|
|
||||||
### Watched Users
|
|
||||||
|
|
||||||
- Add/remove watched Twitch usernames via `POST /api/servers/:guildId/twitch-users` and `DELETE /api/servers/:guildId/twitch-users/:username`.
|
|
||||||
- Frontend polls `/api/twitch/streams` every ~15s to refresh live status and renders a "Watch Live" button per user.
|
|
||||||
- The watcher announces a stream only once per live session; when a user goes offline the session marker clears so a future live event re-announces.
|
|
||||||
|
|
||||||
### SSE Event Types Relevant to Twitch
|
|
||||||
|
|
||||||
- `twitchUsersUpdate`: `{ users: ["user1", "user2"], guildId: "..." }`
|
|
||||||
- `liveNotificationsUpdate`: `{ enabled, channelId, twitchUser, message, customMessage, guildId }`
|
|
||||||
|
|
||||||
Consume these to live-update UI without refresh (the `BackendContext` exposes an `eventTarget`).
|
|
||||||
|
|
||||||
### Customizing Messages
|
|
||||||
|
|
||||||
- In the dashboard under Live Notifications you can set both a Default Message and a Custom Message.
|
|
||||||
- Clear Custom to fall back to Default.
|
|
||||||
- Save persists to backend and pushes an SSE `liveNotificationsUpdate`.
|
|
||||||
|
|
||||||
### Future Improvements
|
|
||||||
|
|
||||||
- Template variable replacement: support `{user}`, `{title}`, `{category}`, `{viewers}` inside message strings.
|
|
||||||
- Per-user custom messages (different prefix for each watched streamer).
|
|
||||||
- Embed image improvements (dynamic preview resolution trimming for Twitch thumbnails).
|
|
||||||
|
|
||||||
Notes about Postgres requirement
|
|
||||||
- The backend now assumes Postgres persistence (via `DATABASE_URL`). If `DATABASE_URL` is not set the server will exit and complain. This change makes server settings authoritative and persistent across restarts.
|
|
||||||
|
|
||||||
Logs and verbosity
|
|
||||||
- The bot and watcher log messages have been reduced to avoid per-guild spam. You will see concise messages like "🔁 TwitchWatcher started" and "✅ ECS - Full Stack Bot Online!" rather than one-line-per-guild spam.
|
|
||||||
|
|
||||||
Troubleshooting
|
|
||||||
- If you see mixed-content errors in the browser when using a TLS domain with the CRA dev server, configure Nginx to proxy websockets and set CRA `WDS_SOCKET_*` env vars (see docs/nginx-proxy-manager.md)
|
|
||||||
|
|||||||
383
backend/index.js
383
backend/index.js
@@ -636,6 +636,8 @@ app.post('/api/servers/:guildId/leave', async (req, res) => {
|
|||||||
const guild = await bot.client.guilds.fetch(guildId);
|
const guild = await bot.client.guilds.fetch(guildId);
|
||||||
if (guild) {
|
if (guild) {
|
||||||
await guild.leave();
|
await guild.leave();
|
||||||
|
// Publish event for bot status change
|
||||||
|
publishEvent('*', 'botStatusUpdate', { guildId, isBotInServer: false });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ success: false, message: 'Bot is not in the specified server' });
|
res.status(404).json({ success: false, message: 'Bot is not in the specified server' });
|
||||||
@@ -654,7 +656,7 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const channels = await guild.channels.fetch();
|
const channels = await guild.channels.fetch();
|
||||||
const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name }));
|
const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name, type: channel.type }));
|
||||||
res.json(textChannels);
|
res.json(textChannels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching channels:', error);
|
console.error('Error fetching channels:', error);
|
||||||
@@ -662,6 +664,40 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/servers/:guildId/members', async (req, res) => {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
const guild = bot.client.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the requesting user from the session/token
|
||||||
|
// For now, we'll assume the frontend sends the user ID in a header or we get it from OAuth
|
||||||
|
// This is a simplified version - in production you'd want proper authentication
|
||||||
|
const members = await guild.members.fetch();
|
||||||
|
|
||||||
|
// Filter to members the bot can interact with and format for frontend
|
||||||
|
const bannableMembers = members
|
||||||
|
.filter(member => !member.user.bot) // Exclude bots
|
||||||
|
.map(member => ({
|
||||||
|
id: member.user.id,
|
||||||
|
username: member.user.username,
|
||||||
|
globalName: member.user.globalName,
|
||||||
|
displayName: member.displayName,
|
||||||
|
avatar: member.user.avatar,
|
||||||
|
joinedAt: member.joinedAt,
|
||||||
|
roles: member.roles.cache.map(role => ({ id: role.id, name: role.name, position: role.position }))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
|
||||||
|
res.json(bannableMembers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching members:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
|
app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
try {
|
try {
|
||||||
@@ -1098,6 +1134,351 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ADMIN LOGS: configuration and retrieval
|
||||||
|
app.get('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
const adminLogsSettings = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } };
|
||||||
|
res.json(adminLogsSettings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching admin logs settings:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
const newSettings = req.body || {};
|
||||||
|
|
||||||
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
const merged = { ...existing };
|
||||||
|
merged.adminLogs = {
|
||||||
|
enabled: newSettings.enabled || false,
|
||||||
|
channelId: newSettings.channelId || '',
|
||||||
|
commands: newSettings.commands || { kick: true, ban: true, timeout: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
await pgClient.upsertServerSettings(guildId, merged);
|
||||||
|
|
||||||
|
// Notify bot of settings change
|
||||||
|
if (bot && bot.setGuildSettings) {
|
||||||
|
bot.setGuildSettings(guildId, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a remote bot push URL is configured, notify it with the new settings
|
||||||
|
if (process.env.BOT_PUSH_URL) {
|
||||||
|
try {
|
||||||
|
const headers = {};
|
||||||
|
if (process.env.INTERNAL_API_KEY) {
|
||||||
|
headers['x-api-key'] = process.env.INTERNAL_API_KEY;
|
||||||
|
}
|
||||||
|
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: merged }, { headers });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to push admin logs settings to bot:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, settings: merged.adminLogs });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving admin logs settings:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/servers/:guildId/admin-logs', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
const { action, limit } = req.query;
|
||||||
|
const limitNum = limit ? parseInt(limit) : 50;
|
||||||
|
|
||||||
|
let logs;
|
||||||
|
if (action) {
|
||||||
|
logs = await pgClient.getAdminLogsByAction(guildId, action, limitNum);
|
||||||
|
} else {
|
||||||
|
logs = await pgClient.getAdminLogs(guildId, limitNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase for frontend compatibility
|
||||||
|
logs = logs.map(log => ({
|
||||||
|
id: log.id,
|
||||||
|
guildId: log.guild_id,
|
||||||
|
action: log.action,
|
||||||
|
targetUserId: log.target_user_id,
|
||||||
|
targetUsername: log.target_username,
|
||||||
|
moderatorUserId: log.moderator_user_id,
|
||||||
|
moderatorUsername: log.moderator_username,
|
||||||
|
reason: log.reason,
|
||||||
|
duration: log.duration,
|
||||||
|
endDate: log.end_date,
|
||||||
|
timestamp: log.timestamp
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(logs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching admin logs:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/servers/:guildId/admin-logs/:logId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { guildId, logId } = req.params;
|
||||||
|
await pgClient.deleteAdminLog(guildId, parseInt(logId));
|
||||||
|
|
||||||
|
// Publish SSE event for live updates
|
||||||
|
publishEvent(guildId, 'adminLogDeleted', { logId: parseInt(logId) });
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting admin log:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/servers/:guildId/admin-logs', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
await pgClient.deleteAllAdminLogs(guildId);
|
||||||
|
|
||||||
|
// Publish SSE event for live updates
|
||||||
|
publishEvent(guildId, 'adminLogsCleared', {});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting all admin logs:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal endpoint for logging moderation actions
|
||||||
|
app.post('/internal/log-moderation', express.json(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate } = req.body;
|
||||||
|
|
||||||
|
if (!guildId || !action || !targetUserId || !moderatorUserId || !reason) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
await pgClient.addAdminLog({
|
||||||
|
guildId,
|
||||||
|
action,
|
||||||
|
targetUserId,
|
||||||
|
targetUsername,
|
||||||
|
moderatorUserId,
|
||||||
|
moderatorUsername,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if logging is enabled for this action and send to Discord channel
|
||||||
|
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
const adminLogs = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } };
|
||||||
|
|
||||||
|
if (adminLogs.enabled && adminLogs.channelId && adminLogs.commands[action]) {
|
||||||
|
const guild = bot.client.guilds.cache.get(guildId);
|
||||||
|
if (guild) {
|
||||||
|
const channel = guild.channels.cache.get(adminLogs.channelId);
|
||||||
|
if (channel && channel.type === 0) { // GUILD_TEXT
|
||||||
|
const embed = {
|
||||||
|
color: action === 'kick' ? 0xffa500 : action === 'ban' ? 0xff0000 : 0x0000ff,
|
||||||
|
title: `🚨 ${action.charAt(0).toUpperCase() + action.slice(1)} Action`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: '👤 Target',
|
||||||
|
value: `${targetUsername} (${targetUserId})`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '👮 Moderator',
|
||||||
|
value: `${moderatorUsername} (${moderatorUserId})`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📝 Reason',
|
||||||
|
value: reason,
|
||||||
|
inline: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
footer: {
|
||||||
|
text: 'ECS Admin Logs'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (duration) {
|
||||||
|
embed.fields.push({
|
||||||
|
name: '⏱️ Duration',
|
||||||
|
value: duration,
|
||||||
|
inline: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
embed.fields.push({
|
||||||
|
name: '📅 End Date',
|
||||||
|
value: new Date(endDate).toLocaleString(),
|
||||||
|
inline: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send admin log to Discord:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish SSE event for live updates
|
||||||
|
publishEvent(guildId, 'adminLogAdded', {
|
||||||
|
log: {
|
||||||
|
guildId,
|
||||||
|
action,
|
||||||
|
targetUserId,
|
||||||
|
targetUsername,
|
||||||
|
moderatorUserId,
|
||||||
|
moderatorUsername,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
endDate,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging moderation action:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// MODERATION: frontend moderation actions
|
||||||
|
app.post('/api/servers/:guildId/moderate', express.json(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
const { action, target, reason, duration, moderator } = req.body;
|
||||||
|
|
||||||
|
if (!action || !target || !reason) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Missing required fields: action, target, reason' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reason has at least 3 words
|
||||||
|
const reasonWords = reason.trim().split(/\s+/);
|
||||||
|
if (reasonWords.length < 3) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Reason must be at least 3 words long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = bot.client.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Guild not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the target user
|
||||||
|
let targetUser = null;
|
||||||
|
let targetMember = null;
|
||||||
|
|
||||||
|
// Try to find by ID first
|
||||||
|
try {
|
||||||
|
targetUser = await bot.client.users.fetch(target);
|
||||||
|
targetMember = guild.members.cache.get(target);
|
||||||
|
} catch (e) {
|
||||||
|
// Try to find by username/mention
|
||||||
|
const members = await guild.members.fetch();
|
||||||
|
targetMember = members.find(m =>
|
||||||
|
m.user.username.toLowerCase().includes(target.toLowerCase()) ||
|
||||||
|
m.user.tag.toLowerCase().includes(target.toLowerCase()) ||
|
||||||
|
(target.startsWith('<@') && target.includes(m.user.id))
|
||||||
|
);
|
||||||
|
if (targetMember) {
|
||||||
|
targetUser = targetMember.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return res.status(404).json({ success: false, message: 'User not found in this server' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the moderation action
|
||||||
|
let result = null;
|
||||||
|
let durationString = null;
|
||||||
|
let endDate = null;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'kick':
|
||||||
|
if (!targetMember) {
|
||||||
|
return res.status(400).json({ success: false, message: 'User is not in this server' });
|
||||||
|
}
|
||||||
|
result = await targetMember.kick(reason);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ban':
|
||||||
|
result = await guild.members.ban(targetUser, { reason });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'timeout':
|
||||||
|
if (!targetMember) {
|
||||||
|
return res.status(400).json({ success: false, message: 'User is not in this server' });
|
||||||
|
}
|
||||||
|
if (!duration || duration < 1 || duration > 40320) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid timeout duration (1-40320 minutes)' });
|
||||||
|
}
|
||||||
|
const timeoutMs = duration * 60 * 1000;
|
||||||
|
endDate = new Date(Date.now() + timeoutMs);
|
||||||
|
result = await targetMember.timeout(timeoutMs, reason);
|
||||||
|
|
||||||
|
// Format duration string
|
||||||
|
if (duration >= 1440) {
|
||||||
|
durationString = `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m`;
|
||||||
|
} else if (duration >= 60) {
|
||||||
|
durationString = `${Math.floor(duration / 60)}h ${duration % 60}m`;
|
||||||
|
} else {
|
||||||
|
durationString = `${duration}m`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the moderation action
|
||||||
|
const moderatorUsername = moderator ? (moderator.global_name || moderator.username || 'Unknown User') : 'Web Interface';
|
||||||
|
try {
|
||||||
|
const logData = {
|
||||||
|
guildId,
|
||||||
|
action,
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
targetUsername: targetUser.global_name || targetUser.username || 'Unknown User',
|
||||||
|
moderatorUserId: moderator?.id || 'web-interface',
|
||||||
|
moderatorUsername,
|
||||||
|
reason,
|
||||||
|
duration: durationString,
|
||||||
|
endDate
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch(`${BACKEND_BASE}/internal/log-moderation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(logData)
|
||||||
|
});
|
||||||
|
} catch (logError) {
|
||||||
|
console.error('Failed to log moderation action:', logError);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: `${action} action completed successfully` });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error performing moderation action:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const bot = require('../discord-bot');
|
const bot = require('../discord-bot');
|
||||||
|
|
||||||
bot.login();
|
bot.login();
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ async function ensureSchema() {
|
|||||||
data JSONB DEFAULT '{}'
|
data JSONB DEFAULT '{}'
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await p.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
guild_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- 'kick', 'ban', 'timeout'
|
||||||
|
target_user_id TEXT NOT NULL,
|
||||||
|
target_username TEXT NOT NULL,
|
||||||
|
moderator_user_id TEXT NOT NULL,
|
||||||
|
moderator_username TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
duration TEXT, -- for timeout/ban (e.g., '1d', '30m', 'permanent')
|
||||||
|
end_date TIMESTAMP WITH TIME ZONE, -- calculated end date for timeout/ban
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Servers
|
// Servers
|
||||||
@@ -76,6 +92,46 @@ async function deleteInvite(guildId, code) {
|
|||||||
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
|
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin Logs
|
||||||
|
async function addAdminLog(logData) {
|
||||||
|
const p = initPool();
|
||||||
|
const q = `INSERT INTO admin_logs(guild_id, action, target_user_id, target_username, moderator_user_id, moderator_username, reason, duration, end_date)
|
||||||
|
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`;
|
||||||
|
await p.query(q, [
|
||||||
|
logData.guildId,
|
||||||
|
logData.action,
|
||||||
|
logData.targetUserId,
|
||||||
|
logData.targetUsername,
|
||||||
|
logData.moderatorUserId,
|
||||||
|
logData.moderatorUsername,
|
||||||
|
logData.reason,
|
||||||
|
logData.duration || null,
|
||||||
|
logData.endDate || null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminLogs(guildId, limit = 50) {
|
||||||
|
const p = initPool();
|
||||||
|
const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 ORDER BY timestamp DESC LIMIT $2', [guildId, limit]);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminLogsByAction(guildId, action, limit = 50) {
|
||||||
|
const p = initPool();
|
||||||
|
const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 AND action = $2 ORDER BY timestamp DESC LIMIT $3', [guildId, action, limit]);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAdminLog(guildId, logId) {
|
||||||
|
const p = initPool();
|
||||||
|
await p.query('DELETE FROM admin_logs WHERE guild_id = $1 AND id = $2', [guildId, logId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllAdminLogs(guildId) {
|
||||||
|
const p = initPool();
|
||||||
|
await p.query('DELETE FROM admin_logs WHERE guild_id = $1', [guildId]);
|
||||||
|
}
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
async function getUserData(discordId) {
|
async function getUserData(discordId) {
|
||||||
const p = initPool();
|
const p = initPool();
|
||||||
@@ -89,4 +145,4 @@ async function upsertUserData(discordId, data) {
|
|||||||
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
|
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData };
|
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs };
|
||||||
|
|||||||
54
checklist.md
54
checklist.md
@@ -1,27 +1,43 @@
|
|||||||
# Project Checklist (tidy & current)
|
# Project Checklist (tidy & current)
|
||||||
|
|
||||||
Below are implemented features and pending items, grouped by area.
|
Below are implemented features - [x] Front - [x] Live updates between bot and frontend using SSE events for real-time log synchronization (admin logs update immediately when moderation actions occur)nd UI for admin logs configuration in Server Settings
|
||||||
|
- [x] Database schema for storing moderation action logs
|
||||||
|
- [x] Require reason field (minimum 3 words) for all moderation commands
|
||||||
|
- [x] Admin Logs UI: added logs display section showing recent moderation actions with detailsd pending items, grouped by area.
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
- [x] Express API: OAuth, server settings, channel/role endpoints, leave
|
- [x] Express API: OAuth, server settings, channel/role endpoints, leave
|
||||||
- [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance
|
- [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance
|
||||||
- [x] Per-command toggles persistence and management
|
- [x] Per-command toggles persistence and management
|
||||||
- [x] Config endpoints for welcome/leave and autorole
|
- [x] Config endpoints for welcome/leave and autorole
|
||||||
|
- [x] Admin Logs API endpoints: GET/POST for admin logs configuration, GET for retrieving moderation action logs
|
||||||
|
- [x] Frontend Moderation API: POST endpoint for direct ban/kick/timeout actions from web interface
|
||||||
|
- [x] Server Members API: GET endpoint for fetching server members for moderation user selection
|
||||||
|
- [x] SSE events: added botStatusUpdate events for real-time bot join/leave notifications
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
- [x] Login, Dashboard, Server Settings pages
|
- [x] Login, Dashboard, Server Settings pages
|
||||||
- Login redirects to Dashboard after OAuth and user/guilds are persisted in localStorage
|
- Login redirects to Dashboard after OAuth and user/guilds are persisted in localStorage
|
||||||
- Dashboard is protected: user must be logged in to view (redirects to login otherwise)
|
- Dashboard is protected: user must be logged in to view (redirects to login otherwise)
|
||||||
- [x] MUI components, responsive layout, mobile fixes
|
- [x] MUI components, responsive layout, mobile fixes
|
||||||
- [x] Theme switching (persist local) and user settings UI
|
- [x] Theme switching (persist local) and user settings UI with adjusted light theme background
|
||||||
- [x] Invite UI: create form, list, copy, delete with confirmation
|
- [x] Invite UI: create form, list, copy, delete with confirmation
|
||||||
- [x] Commands UI (per-command toggles)
|
- [x] Commands UI (per-command toggles)
|
||||||
|
- [x] Admin commands (kick/ban/timeout) removed from regular commands list, only shown in Admin Commands section
|
||||||
- [x] Live Notifications UI (per-server toggle & config)
|
- [x] Live Notifications UI (per-server toggle & config)
|
||||||
- Channel selection, watched-user list, live status with Watch Live button
|
- Channel selection, watched-user list, live status with Watch Live button
|
||||||
- Real-time updates: adding/removing users via frontend or bot commands publishes SSE `twitchUsersUpdate` and pushes settings to bot
|
- Real-time updates: adding/removing users via frontend or bot commands publishes SSE `twitchUsersUpdate` and pushes settings to bot
|
||||||
- Bot commands (`/add-twitchuser`, `/remove-twitchuser`) refresh local cache immediately after backend success
|
- Bot commands (`/add-twitchuser`, `/remove-twitchuser`) refresh local cache immediately after backend success
|
||||||
- Message mode: toggle between Default and Custom; Apply sends `message`/`customMessage` (default fallback if empty); no longer dual free-form fields
|
- Message mode: toggle between Default and Custom; Apply sends `message`/`customMessage` (default fallback if empty); no longer dual free-form fields
|
||||||
- Collapsible accordion interface: separate Twitch and Kick tabs (Kick tab disabled)
|
- Collapsible accordion interface: separate Twitch and Kick tabs (Kick tab disabled)
|
||||||
|
- [x] Admin Commands UI: dedicated section for moderation commands with toggle controls
|
||||||
|
- [x] Moderation Commands (`/kick`, `/ban`, `/timeout`) displayed with permission requirements and toggle switches
|
||||||
|
- [x] Admin Logs Configuration UI: channel selection and per-command enable/disable toggles
|
||||||
|
- [x] Frontend Moderation Actions: direct ban/kick/timeout functionality from web interface with user autocomplete dropdown
|
||||||
|
- [x] User permission validation and reason requirements (minimum 3 words)
|
||||||
|
- [x] Integration with backend moderation API and admin logging system
|
||||||
|
- [x] Admin Logs channel selection: shows all server text channels (not just channels where bot has permission) and updates immediately when changed
|
||||||
|
- [x] Admin logs properly save moderator usernames for both bot slash commands and frontend moderation actions, and persist across page refreshes
|
||||||
|
|
||||||
## Discord Bot
|
## Discord Bot
|
||||||
- [x] discord.js integration (events and commands)
|
- [x] discord.js integration (events and commands)
|
||||||
@@ -54,6 +70,23 @@
|
|||||||
- [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled)
|
- [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled)
|
||||||
- [x] Bot watcher temporarily disabled in index.js startup
|
- [x] Bot watcher temporarily disabled in index.js startup
|
||||||
- [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration
|
- [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration
|
||||||
|
- [x] Admin Moderation Commands: `/kick`, `/ban`, `/timeout` with proper permission checks and role hierarchy validation
|
||||||
|
- [x] Commands accept user mentions or user IDs as input to allow targeting any user (not limited by Discord's user selection filtering)
|
||||||
|
- [x] Frontend integration: web interface moderation actions with permission validation
|
||||||
|
- [x] Moderation actions are logged to postgres database with reasons and automatically posted to configured admin logs channel
|
||||||
|
- [x] Admin logs properly capture and display the moderator who performed the action (both from bot slash commands and frontend)
|
||||||
|
- [x] Admin Logs System: event logging for moderation actions
|
||||||
|
- [x] New slash command: `/setup-adminlogs` to configure logging channel and per-command enable/disable
|
||||||
|
- [x] Bot posts detailed moderation logs to configured channel showing: command used, target user, moderator, date/time, reason (required min 3 words), duration, end date
|
||||||
|
- [x] Backend API endpoints for admin logs configuration and retrieval
|
||||||
|
- [x] Frontend UI for admin logs configuration in Server Settings
|
||||||
|
- [x] Database schema for storing moderation action logs
|
||||||
|
- [x] Require reason field (minimum 3 words) for all moderation commands
|
||||||
|
- [x] Admin logs are unique to each guild and stored in postgres database
|
||||||
|
- [x] Frontend delete buttons for individual logs and delete all logs with confirm dialogs
|
||||||
|
- [x] Live updates between bot and frontend using SSE events for real-time log synchronization
|
||||||
|
- [x] Admin logs properly display the username who called the command and the user they called it on for both bot slash commands and frontend moderation actions
|
||||||
|
- [x] Bot event handlers: added guildCreate and guildDelete events to publish SSE notifications for live dashboard updates
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
||||||
@@ -68,12 +101,16 @@
|
|||||||
- [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON)
|
- [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON)
|
||||||
- Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty)
|
- Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty)
|
||||||
- Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved)
|
- Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved)
|
||||||
|
- [x] Admin Logs Database Schema: new table for storing moderation action logs
|
||||||
|
- Fields: id, guildId, action (kick/ban/timeout), targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate, timestamp
|
||||||
|
|
||||||
## Security & Behavior
|
## Security & Behavior
|
||||||
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
|
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
|
||||||
- [x] Frontend confirmation dialog for invite deletion
|
- [x] Frontend confirmation dialog for invite deletion
|
||||||
- [ ] Harden invite-token issuance (require OAuth + admin check)
|
- [ ] Harden invite-token issuance (require OAuth + admin check)
|
||||||
- [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage`
|
- [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage`
|
||||||
|
- [x] Moderation Command Requirements: require reason field (minimum 3 words) for all moderation commands (`/kick`, `/ban`, `/timeout`)
|
||||||
|
- [x] ServerSettings back button: fixed to navigate to dashboard instead of browser history to prevent accidental accordion opening
|
||||||
|
|
||||||
## Docs & Deployment
|
## Docs & Deployment
|
||||||
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
|
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
|
||||||
@@ -90,6 +127,16 @@
|
|||||||
- Mobile spacing and typography adjustments
|
- Mobile spacing and typography adjustments
|
||||||
- Dashboard action buttons repositioned (Invite/Leave under title)
|
- Dashboard action buttons repositioned (Invite/Leave under title)
|
||||||
- Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled)
|
- Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled)
|
||||||
|
- [x] All accordions in ServerSettings: consistently grayed out (opacity 0.5) when bot is not in server
|
||||||
|
|
||||||
|
- [x] Footer component: added global footer showing "© ehchadservices.com 2025" on all pages
|
||||||
|
- [x] Dashboard live reloading: real-time updates when bot joins/leaves servers via SSE events
|
||||||
|
- [x] Responsive design: mobile-friendly layout with adaptive padding, typography, and component sizing
|
||||||
|
- [x] Ultra-wide screen support: max-width constraints and overflow prevention
|
||||||
|
- [x] Sticky footer: footer positioned at bottom of viewport regardless of content height
|
||||||
|
|
||||||
|
- [x] Navbar branding: title shows "ECS" on mobile, "EhChadServices" on desktop
|
||||||
|
- [x] Dashboard welcome text: updated to "Welcome back, {username}" with even larger typography (h3/h2 variants) and increased spacing; title also enlarged (h4/h3) for better proportion and explicit margin-bottom for clear line separation
|
||||||
|
|
||||||
- [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
|
||||||
@@ -106,5 +153,6 @@
|
|||||||
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
|
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
|
||||||
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
|
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
|
||||||
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management
|
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management
|
||||||
- [ ] Remove legacy top-level duplicate files (archival recommended)
|
- [x] Fixed compilation errors: added missing MUI imports and Snackbar component
|
||||||
|
- [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes
|
||||||
|
|
||||||
172
discord-bot/commands/ban.js
Normal file
172
discord-bot/commands/ban.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
|
|
||||||
|
// Helper function to parse user from mention or ID
|
||||||
|
function parseUser(input, guild) {
|
||||||
|
// Check if it's a mention <@123456> or <@!123456>
|
||||||
|
const mentionMatch = input.match(/^<@!?(\d+)>$/);
|
||||||
|
if (mentionMatch) {
|
||||||
|
return guild.members.cache.get(mentionMatch[1])?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a user ID
|
||||||
|
if (/^\d{15,20}$/.test(input)) {
|
||||||
|
return guild.members.cache.get(input)?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find by username or global name
|
||||||
|
const member = guild.members.cache.find(m =>
|
||||||
|
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
|
||||||
|
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
|
||||||
|
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
|
||||||
|
m.user.username.toLowerCase() === input.toLowerCase()
|
||||||
|
);
|
||||||
|
return member?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to log moderation actions
|
||||||
|
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
|
||||||
|
try {
|
||||||
|
const logData = {
|
||||||
|
guildId,
|
||||||
|
action,
|
||||||
|
targetUserId,
|
||||||
|
targetUsername,
|
||||||
|
moderatorUserId,
|
||||||
|
moderatorUsername,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
endDate
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(logData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to log moderation action:', response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging moderation action:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'ban',
|
||||||
|
description: 'Ban a user from the server',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder()
|
||||||
|
.setName('ban')
|
||||||
|
.setDescription('Ban a user from the server')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('user')
|
||||||
|
.setDescription('The user to ban (mention or user ID)')
|
||||||
|
.setRequired(true))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('reason')
|
||||||
|
.setDescription('Reason for the ban (minimum 3 words)')
|
||||||
|
.setRequired(true))
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option.setName('days')
|
||||||
|
.setDescription('Number of days of messages to delete (0-7)')
|
||||||
|
.setRequired(false)
|
||||||
|
.setMinValue(0)
|
||||||
|
.setMaxValue(7)),
|
||||||
|
async execute(interaction) {
|
||||||
|
// Check if user has ban permissions
|
||||||
|
if (!interaction.member.permissions.has(PermissionsBitField.Flags.BanMembers)) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You do not have permission to ban members.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bot has ban permissions
|
||||||
|
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.BanMembers)) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I do not have permission to ban members.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInput = interaction.options.getString('user');
|
||||||
|
const reason = interaction.options.getString('reason');
|
||||||
|
const days = interaction.options.getInteger('days') || 0;
|
||||||
|
|
||||||
|
// Parse the user from the input
|
||||||
|
const user = parseUser(userInput, interaction.guild);
|
||||||
|
if (!user) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'Could not find that user. Please provide a valid user mention or user ID.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reason has at least 3 words
|
||||||
|
const reasonWords = reason.trim().split(/\s+/);
|
||||||
|
if (reasonWords.length < 3) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'Reason must be at least 3 words long.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot ban yourself
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You cannot ban yourself.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot ban the bot
|
||||||
|
if (user.id === interaction.guild.members.me.id) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I cannot ban myself.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is in the server
|
||||||
|
const member = interaction.guild.members.cache.get(user.id);
|
||||||
|
if (member) {
|
||||||
|
// Check role hierarchy
|
||||||
|
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You cannot ban a member with a higher or equal role.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I cannot ban a member with a higher or equal role.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await interaction.guild.members.ban(user, {
|
||||||
|
reason: reason,
|
||||||
|
deleteMessageDays: days
|
||||||
|
});
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Successfully banned ${user.global_name || user.username} for: ${reason}${days > 0 ? ` (deleted ${days} days of messages)` : ''}`,
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
await logModerationAction(interaction.guildId, 'ban', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, 'permanent');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error banning user:', error);
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Failed to ban the user. Please try again.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
167
discord-bot/commands/kick.js
Normal file
167
discord-bot/commands/kick.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
|
|
||||||
|
// Helper function to parse user from mention or ID
|
||||||
|
function parseUser(input, guild) {
|
||||||
|
// Check if it's a mention <@123456> or <@!123456>
|
||||||
|
const mentionMatch = input.match(/^<@!?(\d+)>$/);
|
||||||
|
if (mentionMatch) {
|
||||||
|
return guild.members.cache.get(mentionMatch[1])?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a user ID
|
||||||
|
if (/^\d{15,20}$/.test(input)) {
|
||||||
|
return guild.members.cache.get(input)?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find by username or global name
|
||||||
|
const member = guild.members.cache.find(m =>
|
||||||
|
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
|
||||||
|
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
|
||||||
|
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
|
||||||
|
m.user.username.toLowerCase() === input.toLowerCase()
|
||||||
|
);
|
||||||
|
return member?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to log moderation actions
|
||||||
|
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
|
||||||
|
try {
|
||||||
|
const logData = {
|
||||||
|
guildId,
|
||||||
|
action,
|
||||||
|
targetUserId,
|
||||||
|
targetUsername,
|
||||||
|
moderatorUserId,
|
||||||
|
moderatorUsername,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
endDate
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(logData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to log moderation action:', response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging moderation action:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'kick',
|
||||||
|
description: 'Kick a user from the server',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder()
|
||||||
|
.setName('kick')
|
||||||
|
.setDescription('Kick a user from the server')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('user')
|
||||||
|
.setDescription('The user to kick (mention or user ID)')
|
||||||
|
.setRequired(true))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('reason')
|
||||||
|
.setDescription('Reason for the kick (minimum 3 words)')
|
||||||
|
.setRequired(true)),
|
||||||
|
async execute(interaction) {
|
||||||
|
// Check if user has kick permissions
|
||||||
|
if (!interaction.member.permissions.has(PermissionsBitField.Flags.KickMembers)) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You do not have permission to kick members.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bot has kick permissions
|
||||||
|
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.KickMembers)) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I do not have permission to kick members.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInput = interaction.options.getString('user');
|
||||||
|
const reason = interaction.options.getString('reason');
|
||||||
|
|
||||||
|
// Parse the user from the input
|
||||||
|
const user = parseUser(userInput, interaction.guild);
|
||||||
|
if (!user) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'Could not find that user. Please provide a valid user mention or user ID.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reason has at least 3 words
|
||||||
|
const reasonWords = reason.trim().split(/\s+/);
|
||||||
|
if (reasonWords.length < 3) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'Reason must be at least 3 words long.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot kick yourself
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You cannot kick yourself.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot kick the bot
|
||||||
|
if (user.id === interaction.guild.members.me.id) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I cannot kick myself.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is in the server
|
||||||
|
const member = interaction.guild.members.cache.get(user.id);
|
||||||
|
if (!member) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'That user is not in this server.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role hierarchy
|
||||||
|
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You cannot kick a member with a higher or equal role.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I cannot kick a member with a higher or equal role.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await member.kick(reason);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Successfully kicked ${user.global_name || user.username} for: ${reason}`,
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
await logModerationAction(interaction.guildId, 'kick', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error kicking user:', error);
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Failed to kick the user. Please try again.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
123
discord-bot/commands/setup-adminlogs.js
Normal file
123
discord-bot/commands/setup-adminlogs.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'setup-adminlogs',
|
||||||
|
description: 'Configure admin moderation logging settings',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder()
|
||||||
|
.setName('setup-adminlogs')
|
||||||
|
.setDescription('Configure admin moderation logging settings')
|
||||||
|
.addChannelOption(option =>
|
||||||
|
option.setName('channel')
|
||||||
|
.setDescription('Channel to send admin logs to')
|
||||||
|
.setRequired(false))
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName('enabled')
|
||||||
|
.setDescription('Enable or disable admin logging')
|
||||||
|
.setRequired(false))
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName('kick_logs')
|
||||||
|
.setDescription('Log kick actions')
|
||||||
|
.setRequired(false))
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName('ban_logs')
|
||||||
|
.setDescription('Log ban actions')
|
||||||
|
.setRequired(false))
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName('timeout_logs')
|
||||||
|
.setDescription('Log timeout actions')
|
||||||
|
.setRequired(false)),
|
||||||
|
async execute(interaction) {
|
||||||
|
// Check if user has administrator permissions
|
||||||
|
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'You need Administrator permissions to configure admin logs.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = interaction.options.getChannel('channel');
|
||||||
|
const enabled = interaction.options.getBoolean('enabled');
|
||||||
|
const kickLogs = interaction.options.getBoolean('kick_logs');
|
||||||
|
const banLogs = interaction.options.getBoolean('ban_logs');
|
||||||
|
const timeoutLogs = interaction.options.getBoolean('timeout_logs');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current settings
|
||||||
|
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch current settings');
|
||||||
|
}
|
||||||
|
const currentSettings = await response.json();
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
const updatedSettings = { ...currentSettings };
|
||||||
|
|
||||||
|
if (enabled !== null) {
|
||||||
|
updatedSettings.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel) {
|
||||||
|
// Check if it's a text channel
|
||||||
|
if (channel.type !== 0) { // 0 = GUILD_TEXT
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'Please select a text channel for admin logs.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updatedSettings.channelId = channel.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update command-specific settings
|
||||||
|
updatedSettings.commands = { ...updatedSettings.commands };
|
||||||
|
if (kickLogs !== null) {
|
||||||
|
updatedSettings.commands.kick = kickLogs;
|
||||||
|
}
|
||||||
|
if (banLogs !== null) {
|
||||||
|
updatedSettings.commands.ban = banLogs;
|
||||||
|
}
|
||||||
|
if (timeoutLogs !== null) {
|
||||||
|
updatedSettings.commands.timeout = timeoutLogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
const saveResponse = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updatedSettings)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!saveResponse.ok) {
|
||||||
|
throw new Error('Failed to save settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await saveResponse.json();
|
||||||
|
|
||||||
|
// Create response message
|
||||||
|
let responseMessage = 'Admin logs settings updated!\n\n';
|
||||||
|
responseMessage += `**Enabled:** ${result.settings.enabled ? 'Yes' : 'No'}\n`;
|
||||||
|
if (result.settings.channelId) {
|
||||||
|
responseMessage += `**Channel:** <#${result.settings.channelId}>\n`;
|
||||||
|
} else {
|
||||||
|
responseMessage += `**Channel:** Not set\n`;
|
||||||
|
}
|
||||||
|
responseMessage += `**Kick Logs:** ${result.settings.commands.kick ? 'Enabled' : 'Disabled'}\n`;
|
||||||
|
responseMessage += `**Ban Logs:** ${result.settings.commands.ban ? 'Enabled' : 'Disabled'}\n`;
|
||||||
|
responseMessage += `**Timeout Logs:** ${result.settings.commands.timeout ? 'Enabled' : 'Disabled'}`;
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: responseMessage,
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error configuring admin logs:', error);
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'An error occurred while configuring admin logs. Please try again later.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
187
discord-bot/commands/timeout.js
Normal file
187
discord-bot/commands/timeout.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
|
|
||||||
|
// Helper function to parse user from mention or ID
|
||||||
|
function parseUser(input, guild) {
|
||||||
|
// Check if it's a mention <@123456> or <@!123456>
|
||||||
|
const mentionMatch = input.match(/^<@!?(\d+)>$/);
|
||||||
|
if (mentionMatch) {
|
||||||
|
return guild.members.cache.get(mentionMatch[1])?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a user ID
|
||||||
|
if (/^\d{15,20}$/.test(input)) {
|
||||||
|
return guild.members.cache.get(input)?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find by username or global name
|
||||||
|
const member = guild.members.cache.find(m =>
|
||||||
|
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
|
||||||
|
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
|
||||||
|
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
|
||||||
|
m.user.username.toLowerCase() === input.toLowerCase()
|
||||||
|
);
|
||||||
|
return member?.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to log moderation actions
|
||||||
|
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
|
||||||
|
try {
|
||||||
|
const logData = {
|
||||||
|
guildId,
|
||||||
|
action,
|
||||||
|
targetUserId,
|
||||||
|
targetUsername,
|
||||||
|
moderatorUserId,
|
||||||
|
moderatorUsername,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
endDate
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(logData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to log moderation action:', response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging moderation action:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'timeout',
|
||||||
|
description: 'Timeout a user in the server',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder()
|
||||||
|
.setName('timeout')
|
||||||
|
.setDescription('Timeout a user in the server')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('user')
|
||||||
|
.setDescription('The user to timeout (mention or user ID)')
|
||||||
|
.setRequired(true))
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option.setName('duration')
|
||||||
|
.setDescription('Duration in minutes (max 40320 minutes = 28 days)')
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(40320))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('reason')
|
||||||
|
.setDescription('Reason for the timeout (minimum 3 words)')
|
||||||
|
.setRequired(true)),
|
||||||
|
async execute(interaction) {
|
||||||
|
// Check if user has moderate members permissions (required for timeout)
|
||||||
|
if (!interaction.member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You do not have permission to timeout members.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bot has moderate members permissions
|
||||||
|
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.ModerateMembers)) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I do not have permission to timeout members.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInput = interaction.options.getString('user');
|
||||||
|
const duration = interaction.options.getInteger('duration');
|
||||||
|
const reason = interaction.options.getString('reason');
|
||||||
|
|
||||||
|
// Parse the user from the input
|
||||||
|
const user = parseUser(userInput, interaction.guild);
|
||||||
|
if (!user) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'Could not find that user. Please provide a valid user mention or user ID.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reason has at least 3 words
|
||||||
|
const reasonWords = reason.trim().split(/\s+/);
|
||||||
|
if (reasonWords.length < 3) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'Reason must be at least 3 words long.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot timeout yourself
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You cannot timeout yourself.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot timeout the bot
|
||||||
|
if (user.id === interaction.guild.members.me.id) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I cannot timeout myself.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is in the server
|
||||||
|
const member = interaction.guild.members.cache.get(user.id);
|
||||||
|
if (!member) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'That user is not in this server.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role hierarchy
|
||||||
|
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'You cannot timeout a member with a higher or equal role.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'I cannot timeout a member with a higher or equal role.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already timed out
|
||||||
|
if (member.communicationDisabledUntil) {
|
||||||
|
return await interaction.reply({
|
||||||
|
content: 'This user is already timed out.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeoutDuration = duration * 60 * 1000; // Convert minutes to milliseconds
|
||||||
|
const timeoutUntil = new Date(Date.now() + timeoutDuration);
|
||||||
|
|
||||||
|
await member.timeout(timeoutDuration, reason);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Successfully timed out ${user.global_name || user.username} for ${duration} minutes. Reason: ${reason}`,
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
const durationString = duration >= 1440 ? `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m` :
|
||||||
|
duration >= 60 ? `${Math.floor(duration / 60)}h ${duration % 60}m` : `${duration}m`;
|
||||||
|
await logModerationAction(interaction.guildId, 'timeout', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, durationString, timeoutUntil);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error timing out user:', error);
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Failed to timeout the user. Please try again.',
|
||||||
|
flags: 64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -37,4 +37,24 @@ const deployCommands = async (guildId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Standalone execution
|
||||||
|
if (require.main === module) {
|
||||||
|
const { Client, GatewayIntentBits } = require('discord.js');
|
||||||
|
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||||
|
|
||||||
|
client.once('ready', async () => {
|
||||||
|
console.log(`Logged in as ${client.user.tag}`);
|
||||||
|
console.log(`Deploying commands to ${client.guilds.cache.size} guilds...`);
|
||||||
|
|
||||||
|
for (const [guildId, guild] of client.guilds.cache) {
|
||||||
|
await deployCommands(guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All commands deployed!');
|
||||||
|
client.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = deployCommands;
|
module.exports = deployCommands;
|
||||||
|
|||||||
15
discord-bot/events/guildCreate.js
Normal file
15
discord-bot/events/guildCreate.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const api = require('../api');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'guildCreate',
|
||||||
|
execute: async (guild, client) => {
|
||||||
|
console.log(`Bot joined guild: ${guild.name} (${guild.id})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Publish SSE event for bot status change
|
||||||
|
await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing bot join event:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
15
discord-bot/events/guildDelete.js
Normal file
15
discord-bot/events/guildDelete.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const api = require('../api');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'guildDelete',
|
||||||
|
execute: async (guild, client) => {
|
||||||
|
console.log(`Bot left guild: ${guild.name} (${guild.id})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Publish SSE event for bot status change
|
||||||
|
await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing bot leave event:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
149
frontend/package-lock.json
generated
149
frontend/package-lock.json
generated
@@ -3580,9 +3580,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rushstack/eslint-patch": {
|
"node_modules/@rushstack/eslint-patch": {
|
||||||
"version": "1.12.0",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.0.tgz",
|
||||||
"integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==",
|
"integrity": "sha512-WJFej426qe4RWOm9MMtP4V3CV4AucXolQty+GRgAWLgQXmpCuwzs7hEpxxhSc/znXUSxum9d/P/32MW0FlAAlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
@@ -4080,9 +4080,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/express-serve-static-core": {
|
"node_modules/@types/express-serve-static-core": {
|
||||||
"version": "5.0.7",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
||||||
"integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==",
|
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -4092,9 +4092,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/express/node_modules/@types/express-serve-static-core": {
|
"node_modules/@types/express/node_modules/@types/express-serve-static-core": {
|
||||||
"version": "4.19.6",
|
"version": "4.19.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
|
||||||
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
|
"integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -4176,12 +4176,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.6.2",
|
"version": "24.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||||
"integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==",
|
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.13.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node-forge": {
|
"node_modules/@types/node-forge": {
|
||||||
@@ -4270,12 +4270,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/send": {
|
"node_modules/@types/send": {
|
||||||
"version": "0.17.5",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
|
||||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
"integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime": "^1",
|
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4289,14 +4288,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/serve-static": {
|
"node_modules/@types/serve-static": {
|
||||||
"version": "1.15.8",
|
"version": "1.15.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
|
||||||
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
|
"integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"@types/send": "*"
|
"@types/send": "<1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static/node_modules/@types/send": {
|
||||||
|
"version": "0.17.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||||
|
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mime": "^1",
|
||||||
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/sockjs": {
|
"node_modules/@types/sockjs": {
|
||||||
@@ -5330,9 +5339,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axe-core": {
|
"node_modules/axe-core": {
|
||||||
"version": "4.10.3",
|
"version": "4.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz",
|
||||||
"integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
|
"integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
@@ -5635,9 +5644,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.10",
|
"version": "2.8.18",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||||
"integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==",
|
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
@@ -5956,9 +5965,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001746",
|
"version": "1.0.30001751",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||||
"integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==",
|
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -6218,9 +6227,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/collect-v8-coverage": {
|
"node_modules/collect-v8-coverage": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
||||||
"integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
|
"integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
@@ -6398,9 +6407,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.45.1",
|
"version": "3.46.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
|
||||||
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
|
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -6409,12 +6418,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js-compat": {
|
"node_modules/core-js-compat": {
|
||||||
"version": "3.45.1",
|
"version": "3.46.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz",
|
||||||
"integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==",
|
"integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"browserslist": "^4.25.3"
|
"browserslist": "^4.26.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -6422,9 +6431,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js-pure": {
|
"node_modules/core-js-pure": {
|
||||||
"version": "3.45.1",
|
"version": "3.46.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.46.0.tgz",
|
||||||
"integrity": "sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ==",
|
"integrity": "sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -7344,9 +7353,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.229",
|
"version": "1.5.237",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.229.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||||
"integrity": "sha512-cwhDcZKGcT/rEthLRJ9eBlMDkh1sorgsuk+6dpsehV0g9CABsIqBxU4rLRjG+d/U6pYU1s37A4lSKrVc5lSQYg==",
|
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/emittery": {
|
"node_modules/emittery": {
|
||||||
@@ -11648,12 +11657,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/loader-runner": {
|
"node_modules/loader-runner": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.11.5"
|
"node": ">=6.11.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/loader-utils": {
|
"node_modules/loader-utils": {
|
||||||
@@ -12101,9 +12114,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.21",
|
"version": "2.0.26",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||||
"integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==",
|
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
@@ -15197,9 +15210,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -16951,9 +16964,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "4.9.5",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -16961,7 +16974,7 @@
|
|||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.2.0"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
@@ -16989,9 +17002,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.13.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
@@ -17272,9 +17285,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webpack": {
|
"node_modules/webpack": {
|
||||||
"version": "5.102.0",
|
"version": "5.102.1",
|
||||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz",
|
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||||
"integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==",
|
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/eslint-scope": "^3.7.7",
|
"@types/eslint-scope": "^3.7.7",
|
||||||
@@ -17285,7 +17298,7 @@
|
|||||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
"acorn-import-phases": "^1.0.3",
|
"acorn-import-phases": "^1.0.3",
|
||||||
"browserslist": "^4.24.5",
|
"browserslist": "^4.26.3",
|
||||||
"chrome-trace-event": "^1.0.2",
|
"chrome-trace-event": "^1.0.2",
|
||||||
"enhanced-resolve": "^5.17.3",
|
"enhanced-resolve": "^5.17.3",
|
||||||
"es-module-lexer": "^1.2.1",
|
"es-module-lexer": "^1.2.1",
|
||||||
@@ -17297,8 +17310,8 @@
|
|||||||
"loader-runner": "^4.2.0",
|
"loader-runner": "^4.2.0",
|
||||||
"mime-types": "^2.1.27",
|
"mime-types": "^2.1.27",
|
||||||
"neo-async": "^2.6.2",
|
"neo-async": "^2.6.2",
|
||||||
"schema-utils": "^4.3.2",
|
"schema-utils": "^4.3.3",
|
||||||
"tapable": "^2.2.3",
|
"tapable": "^2.3.0",
|
||||||
"terser-webpack-plugin": "^5.3.11",
|
"terser-webpack-plugin": "^5.3.11",
|
||||||
"watchpack": "^2.4.4",
|
"watchpack": "^2.4.4",
|
||||||
"webpack-sources": "^3.3.3"
|
"webpack-sources": "^3.3.3"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-route
|
|||||||
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 { BackendProvider, useBackend } from './contexts/BackendContext';
|
||||||
import { CssBaseline } from '@mui/material';
|
import { CssBaseline, Box } 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/server/ServerSettings';
|
import ServerSettings from './components/server/ServerSettings';
|
||||||
@@ -11,6 +11,7 @@ import NavBar from './components/NavBar';
|
|||||||
import HelpPage from './components/server/HelpPage';
|
import HelpPage from './components/server/HelpPage';
|
||||||
import DiscordPage from './components/DiscordPage';
|
import DiscordPage from './components/DiscordPage';
|
||||||
import MaintenancePage from './components/common/MaintenancePage';
|
import MaintenancePage from './components/common/MaintenancePage';
|
||||||
|
import Footer from './components/common/Footer';
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const { backendOnline, checking, forceCheck } = useBackend();
|
const { backendOnline, checking, forceCheck } = useBackend();
|
||||||
@@ -23,6 +24,13 @@ function AppInner() {
|
|||||||
<UserProvider>
|
<UserProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Router>
|
<Router>
|
||||||
<TitleSetter />
|
<TitleSetter />
|
||||||
{!backendOnline ? (
|
{!backendOnline ? (
|
||||||
@@ -30,6 +38,14 @@ function AppInner() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Login />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
@@ -37,9 +53,12 @@ function AppInner() {
|
|||||||
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
||||||
<Route path="/discord" element={<DiscordPage />} />
|
<Route path="/discord" element={<DiscordPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
<Footer />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Router>
|
</Router>
|
||||||
|
</Box>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
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 MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import { UserContext } from '../contexts/UserContext';
|
import { UserContext } from '../contexts/UserContext';
|
||||||
|
import { useBackend } from '../contexts/BackendContext';
|
||||||
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 DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
|
import WavingHandIcon from '@mui/icons-material/WavingHand';
|
||||||
import { get, post } from '../lib/api';
|
import { get, post } from '../lib/api';
|
||||||
|
|
||||||
import ConfirmDialog from './common/ConfirmDialog';
|
import ConfirmDialog from './common/ConfirmDialog';
|
||||||
@@ -13,6 +16,7 @@ const Dashboard = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, setUser } = useContext(UserContext);
|
const { user, setUser } = useContext(UserContext);
|
||||||
|
const { eventTarget } = useBackend();
|
||||||
|
|
||||||
const [guilds, setGuilds] = useState([]);
|
const [guilds, setGuilds] = useState([]);
|
||||||
const [botStatus, setBotStatus] = useState({});
|
const [botStatus, setBotStatus] = useState({});
|
||||||
@@ -89,6 +93,25 @@ const Dashboard = () => {
|
|||||||
fetchStatuses();
|
fetchStatuses();
|
||||||
}, [guilds, API_BASE]);
|
}, [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
|
// Dashboard no longer loads live settings; that's on the server settings page
|
||||||
|
|
||||||
// Live notifications handlers were removed from Dashboard
|
// Live notifications handlers were removed from Dashboard
|
||||||
@@ -139,18 +162,32 @@ const Dashboard = () => {
|
|||||||
const handleSnackbarClose = () => setSnackbarOpen(false);
|
const handleSnackbarClose = () => setSnackbarOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20 }}>
|
<Container maxWidth="lg" sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>Dashboard</Typography>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||||
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
|
<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>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
|
<Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
|
||||||
|
|
||||||
<Grid container spacing={3} justifyContent="center">
|
<Grid container spacing={3} justifyContent="center">
|
||||||
{guilds.map(guild => (
|
{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%' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||||
<Card
|
<Card
|
||||||
onClick={() => handleCardClick(guild)}
|
onClick={() => handleCardClick(guild)}
|
||||||
@@ -163,6 +200,11 @@ const Dashboard = () => {
|
|||||||
transform: 'translateY(-5px)',
|
transform: 'translateY(-5px)',
|
||||||
boxShadow: '0 12px 24px rgba(0,0,0,0.3)',
|
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%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -171,30 +213,66 @@ const Dashboard = () => {
|
|||||||
overflow: 'hidden',
|
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
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
|
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
|
||||||
sx={{
|
sx={{
|
||||||
width: 80,
|
width: { xs: 60, sm: 80 },
|
||||||
height: 80,
|
height: { xs: 60, sm: 80 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
mb: 2,
|
mb: 2,
|
||||||
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
|
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, textAlign: 'center', mb: 1 }}>{guild.name}</Typography>
|
<Typography
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
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] ? (
|
{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
|
Leave
|
||||||
</Button>
|
</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
|
Invite
|
||||||
</Button>
|
</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 />
|
<MoreVertIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -225,7 +303,7 @@ const Dashboard = () => {
|
|||||||
title="Confirm Leave"
|
title="Confirm Leave"
|
||||||
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
|
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const NavBar = () => {
|
|||||||
const closeMenu = () => { setAnchorEl(null); setOpen(false); };
|
const closeMenu = () => { setAnchorEl(null); setOpen(false); };
|
||||||
|
|
||||||
return (
|
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 } }}>
|
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: { xs: 2, sm: 4 } }}>
|
||||||
<Box>
|
<Box>
|
||||||
<IconButton onClick={toggleOpen} aria-label="menu" size="large" sx={{ bgcolor: open ? 'primary.main' : 'transparent', color: open ? 'white' : 'text.primary' }}>
|
<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>
|
</IconButton>
|
||||||
</Box>
|
</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(); }}>
|
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
|
||||||
ECS - EHDCHADSWORTH
|
EhChadServices
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
|||||||
32
frontend/src/components/common/Footer.js
Normal file
32
frontend/src/components/common/Footer.js
Normal 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;
|
||||||
@@ -26,10 +26,10 @@ const HelpPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20 }}>
|
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, mb: 2 }}>
|
||||||
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
|
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
|
||||||
<Typography variant="h5">Commands List</Typography>
|
<Typography variant={{ xs: 'h5', sm: 'h5' }}>Commands List</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ marginTop: 2 }}>
|
<Box sx={{ marginTop: 2 }}>
|
||||||
{commands.length === 0 && <Typography>No commands available.</Typography>}
|
{commands.length === 0 && <Typography>No commands available.</Typography>}
|
||||||
@@ -43,7 +43,7 @@ const HelpPage = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
import { useBackend } from '../../contexts/BackendContext';
|
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, 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 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 '../common/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';
|
||||||
|
import { UserContext } from '../../contexts/UserContext';
|
||||||
|
|
||||||
// Use a relative API base by default so the frontend talks to the same origin that served it.
|
// 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.
|
// 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 { guildId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { user } = useContext(UserContext);
|
||||||
|
|
||||||
// settings state removed (not used) to avoid lint warnings
|
// settings state removed (not used) to avoid lint warnings
|
||||||
const [isBotInServer, setIsBotInServer] = useState(false);
|
const [isBotInServer, setIsBotInServer] = useState(false);
|
||||||
@@ -35,8 +37,6 @@ const ServerSettings = () => {
|
|||||||
const [deleting, setDeleting] = useState({});
|
const [deleting, setDeleting] = useState({});
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
|
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
|
||||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
|
||||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
|
||||||
// SSE connection status (not currently displayed)
|
// SSE connection status (not currently displayed)
|
||||||
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
|
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
|
||||||
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
|
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
|
||||||
@@ -44,18 +44,6 @@ const ServerSettings = () => {
|
|||||||
const [pendingKickUser, setPendingKickUser] = useState(null);
|
const [pendingKickUser, setPendingKickUser] = useState(null);
|
||||||
const [commandsExpanded, setCommandsExpanded] = useState(false);
|
const [commandsExpanded, setCommandsExpanded] = useState(false);
|
||||||
const [liveExpanded, setLiveExpanded] = 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({
|
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
|
||||||
welcome: {
|
welcome: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -70,6 +58,39 @@ const ServerSettings = () => {
|
|||||||
customMessage: '',
|
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 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}."];
|
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
|
||||||
@@ -104,12 +125,12 @@ const ServerSettings = () => {
|
|||||||
// ignore when offline
|
// ignore when offline
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch channels
|
// Fetch bot status
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
|
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`).then(response => {
|
||||||
setChannels(response.data);
|
setIsBotInServer(response.data.isBotInServer);
|
||||||
}).catch(() => {
|
}).catch(() => setIsBotInServer(false));
|
||||||
setChannels([]);
|
|
||||||
});
|
// Fetch channels - moved to separate useEffect to depend on bot status
|
||||||
|
|
||||||
// Fetch welcome/leave settings
|
// Fetch welcome/leave settings
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
|
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
|
||||||
@@ -144,18 +165,46 @@ const ServerSettings = () => {
|
|||||||
setLiveCustomMessage(s.customMessage || '');
|
setLiveCustomMessage(s.customMessage || '');
|
||||||
}).catch(() => {});
|
}).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}/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}/kick-users`).then(resp => setKickUsers(resp.data || [])).catch(() => setKickUsers([]));
|
||||||
|
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
|
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
|
||||||
|
|
||||||
|
// 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
|
// Open commands accordion if navigated from Help back button
|
||||||
if (location.state && location.state.openCommands) {
|
if (location.state && location.state.openCommands) {
|
||||||
setCommandsExpanded(true);
|
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
|
// Listen to backend events for live notifications and twitch user updates
|
||||||
const { eventTarget } = useBackend();
|
const { eventTarget } = useBackend();
|
||||||
@@ -198,10 +247,35 @@ const ServerSettings = () => {
|
|||||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
|
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('twitchUsersUpdate', onTwitchUsers);
|
||||||
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
||||||
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||||
eventTarget.addEventListener('commandToggle', onCommandToggle);
|
eventTarget.addEventListener('commandToggle', onCommandToggle);
|
||||||
|
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
|
||||||
|
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||||
|
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
@@ -209,12 +283,15 @@ const ServerSettings = () => {
|
|||||||
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
|
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
|
||||||
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
|
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||||
eventTarget.removeEventListener('commandToggle', onCommandToggle);
|
eventTarget.removeEventListener('commandToggle', onCommandToggle);
|
||||||
|
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
|
||||||
|
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||||
|
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
};
|
};
|
||||||
}, [eventTarget, guildId]);
|
}, [eventTarget, guildId]);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
navigate(-1);
|
navigate('/dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleLive = async (e) => {
|
const handleToggleLive = async (e) => {
|
||||||
@@ -229,10 +306,6 @@ const ServerSettings = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseSnackbar = () => {
|
|
||||||
setSnackbarOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 => {
|
||||||
@@ -330,6 +403,100 @@ const ServerSettings = () => {
|
|||||||
setDialogOpen(false);
|
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.
|
// Poll Twitch live status for watched users (simple interval). Avoid spamming when list empty or feature disabled.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer = null;
|
let timer = null;
|
||||||
@@ -368,7 +535,7 @@ const ServerSettings = () => {
|
|||||||
const login = (s.user_login || '').toLowerCase();
|
const login = (s.user_login || '').toLowerCase();
|
||||||
map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count };
|
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) {
|
} catch (e) {
|
||||||
// network errors ignored
|
// network errors ignored
|
||||||
}
|
}
|
||||||
@@ -379,13 +546,31 @@ const ServerSettings = () => {
|
|||||||
}, [kickUsers]);
|
}, [kickUsers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px' }}>
|
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<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)' }}>
|
<IconButton onClick={handleBack} sx={{ borderRadius: '50%', boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)' }}>
|
||||||
<ArrowBackIcon />
|
<ArrowBackIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="h4" component="h1" sx={{ margin: 0 }}>
|
<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...'}
|
{server ? `Server Settings for ${server.name}` : 'Loading...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
{isBotInServer ? (
|
{isBotInServer ? (
|
||||||
@@ -418,7 +603,8 @@ const ServerSettings = () => {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const protectedOrder = ['help', 'manage-commands'];
|
const protectedOrder = ['help', 'manage-commands'];
|
||||||
const protectedCmds = protectedOrder.map(name => commandsList.find(c => c.name === name)).filter(Boolean);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{protectedCmds.map(cmd => (
|
{protectedCmds.map(cmd => (
|
||||||
@@ -664,7 +850,7 @@ const ServerSettings = () => {
|
|||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
{/* Live Notifications 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 />}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
<Typography variant="h6">Live Notifications</Typography>
|
<Typography variant="h6">Live Notifications</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
@@ -834,7 +1020,282 @@ const ServerSettings = () => {
|
|||||||
<Typography variant="h6">Admin Commands</Typography>
|
<Typography variant="h6">Admin Commands</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails>
|
<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>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -936,7 +1397,28 @@ const ServerSettings = () => {
|
|||||||
title="Delete Kick User"
|
title="Delete Kick User"
|
||||||
message={`Are you sure you want to remove ${pendingKickUser || ''} from the watch list?`}
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export function BackendProvider({ children }) {
|
|||||||
es.addEventListener('commandToggle', forward('commandToggle'));
|
es.addEventListener('commandToggle', forward('commandToggle'));
|
||||||
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
|
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
|
||||||
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
|
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
|
||||||
|
es.addEventListener('adminLogAdded', forward('adminLogAdded'));
|
||||||
|
es.addEventListener('adminLogDeleted', forward('adminLogDeleted'));
|
||||||
|
es.addEventListener('adminLogsCleared', forward('adminLogsCleared'));
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
// Let consumers react to backendOnline state changes instead of surfacing connection errors
|
// Let consumers react to backendOnline state changes instead of surfacing connection errors
|
||||||
|
|||||||
@@ -11,3 +11,28 @@ code {
|
|||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { createTheme } from '@mui/material/styles';
|
|||||||
export const lightTheme = createTheme({
|
export const lightTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
|
background: {
|
||||||
|
default: '#e8e8e8', // More greyish background, less bright white
|
||||||
|
paper: '#ffffff',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
main: '#1565c0', // Slightly darker blue for less brightness
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "ECS-FullStack",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user