diff --git a/backend/index.js b/backend/index.js
index 7d2d28a..90510bd 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -283,6 +283,123 @@ app.get('/api/servers/:guildId/commands', (req, res) => {
}
});
+// INVITES: create, list, delete
+app.get('/api/servers/:guildId/invites', async (req, res) => {
+ try {
+ const { guildId } = req.params;
+ const db = readDb();
+ const saved = (db[guildId] && db[guildId].invites) ? db[guildId].invites : [];
+
+ // try to enrich with live data where possible
+ const guild = bot.client.guilds.cache.get(guildId);
+ let liveInvites = [];
+ if (guild) {
+ try {
+ const fetched = await guild.invites.fetch();
+ liveInvites = Array.from(fetched.values());
+ } catch (e) {
+ // ignore fetch errors
+ }
+ }
+
+ const combined = saved.map(inv => {
+ const live = liveInvites.find(li => li.code === inv.code);
+ return {
+ ...inv,
+ uses: live ? live.uses : inv.uses || 0,
+ maxUses: inv.maxUses || (live ? live.maxUses : 0),
+ maxAge: inv.maxAge || (live ? live.maxAge : 0),
+ };
+ });
+
+ res.json(combined);
+ } catch (error) {
+ console.error('Error listing invites:', error);
+ res.status(500).json({ success: false, message: 'Internal Server Error' });
+ }
+});
+
+app.post('/api/servers/:guildId/invites', async (req, res) => {
+ try {
+ const { guildId } = req.params;
+ const { channelId, maxAge, maxUses, temporary } = req.body || {};
+ const guild = bot.client.guilds.cache.get(guildId);
+ if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
+
+ let channel = null;
+ if (channelId) {
+ try { channel = await guild.channels.fetch(channelId); } catch (e) { channel = null; }
+ }
+ if (!channel) {
+ // fall back to first text channel
+ const channels = await guild.channels.fetch();
+ channel = channels.find(c => c.type === 0) || channels.first();
+ }
+ if (!channel) return res.status(400).json({ success: false, message: 'No channel available to create invite' });
+
+ const inviteOptions = {
+ maxAge: typeof maxAge === 'number' ? maxAge : 0,
+ maxUses: typeof maxUses === 'number' ? maxUses : 0,
+ temporary: !!temporary,
+ unique: true,
+ };
+
+ const invite = await channel.createInvite(inviteOptions);
+
+ const db = readDb();
+ if (!db[guildId]) db[guildId] = {};
+ if (!db[guildId].invites) db[guildId].invites = [];
+
+ const item = {
+ code: invite.code,
+ url: invite.url,
+ channelId: channel.id,
+ createdAt: new Date().toISOString(),
+ maxUses: invite.maxUses || inviteOptions.maxUses || 0,
+ maxAge: invite.maxAge || inviteOptions.maxAge || 0,
+ temporary: !!invite.temporary,
+ };
+
+ db[guildId].invites.push(item);
+ writeDb(db);
+
+ res.json({ success: true, invite: item });
+ } catch (error) {
+ console.error('Error creating invite:', error);
+ res.status(500).json({ success: false, message: 'Internal Server Error' });
+ }
+});
+
+app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
+ try {
+ const { guildId, code } = req.params;
+ const db = readDb();
+ const guild = bot.client.guilds.cache.get(guildId);
+
+ // Try to delete on Discord if possible
+ if (guild) {
+ try {
+ // fetch invites and delete matching code
+ const fetched = await guild.invites.fetch();
+ const inv = fetched.find(i => i.code === code);
+ if (inv) await inv.delete();
+ } catch (e) {
+ // ignore
+ }
+ }
+
+ if (db[guildId] && db[guildId].invites) {
+ db[guildId].invites = db[guildId].invites.filter(i => i.code !== code);
+ writeDb(db);
+ }
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting invite:', error);
+ res.status(500).json({ success: false, message: 'Internal Server Error' });
+ }
+});
+
const bot = require('../discord-bot');
bot.login();
diff --git a/checklist.md b/checklist.md
index b5c40ad..e4ead4e 100644
--- a/checklist.md
+++ b/checklist.md
@@ -40,6 +40,13 @@
- [x] Improve NavBar layout and styling for clarity and compactness
- [x] Implement single-hamburger NavBar (hamburger toggles to X; buttons hidden when collapsed)
- [x] Commands List button added above Commands accordion in Server Settings
+ - [ ] Add server invite management
+ - [ ] Add UI to create invites: optional channel dropdown, maxAge dropdown, maxUses dropdown, temporary toggle, create button
+ - [ ] Allow invite creation without selecting a channel (use default)
+ - [ ] Persist created invites to backend encrypted DB
+ - [ ] Add front-end list showing created invites with Copy and Delete actions and metadata (url, createdAt, uses, maxUses, maxAge, temporary)
+ - [ ] Add `/create-invite` and `/list-invites` slash commands in the bot; ensure actions sync with backend
+ - [ ] Add enable/disable toggles for these commands in Commands list
- [x] Place 'Invite' button beside the server title on dashboard/server cards
- Acceptance criteria: the invite button appears horizontally adjacent to the server title (to the right), remains visible and usable on tablet and desktop layouts, is keyboard-focusable, and has an accessible aria-label (e.g. "Invite bot to SERVER_NAME").
- [x] Show the server name in a rounded "bubble" and render it bold
@@ -60,6 +67,14 @@
- [x] Redesign the login page to be more bubbly, centered, and eye-catching, with bigger text and responsive design.
- [x] Make server settings panels collapsible for a cleaner mobile UI.
+## Recent frontend tweaks
+
+- [x] Commands list sorted alphabetically in Server Settings for easier scanning
+- [x] Invite creation form: labels added above dropdowns (Channel, Expiry, Max Uses, Temporary) and layout improved for mobile (stacked inputs)
+ - [x] Theme persistence: theme changes now persist immediately (localStorage) and are not overwritten on page navigation; server-side preference is respected when different from local selection
+ - [x] Theme preference behavior: UI now prefers an explicit user selection (localStorage) over defaults; default is used only on first visit when no prior selection exists
+
+
## Discord Bot
- [x] Create a basic Discord bot
- [x] Add a feature with both slash and web commands
diff --git a/discord-bot/commands/create-invite.js b/discord-bot/commands/create-invite.js
new file mode 100644
index 0000000..6b03b1d
--- /dev/null
+++ b/discord-bot/commands/create-invite.js
@@ -0,0 +1,54 @@
+const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
+const { readDb, writeDb } = require('../../backend/db');
+
+module.exports = {
+ name: 'create-invite',
+ description: 'Create a Discord invite with options (channel optional, maxAge seconds, maxUses, temporary).',
+ enabled: true,
+ builder: new SlashCommandBuilder()
+ .setName('create-invite')
+ .setDescription('Create a Discord invite with options (channel optional, maxAge seconds, maxUses, temporary).')
+ .addChannelOption(opt => opt.setName('channel').setDescription('Channel to create invite in').setRequired(false))
+ .addIntegerOption(opt => opt.setName('maxage').setDescription('Duration in seconds (0 means never expire)').setRequired(false))
+ .addIntegerOption(opt => opt.setName('maxuses').setDescription('Number of uses allowed (0 means unlimited)').setRequired(false))
+ .addBooleanOption(opt => opt.setName('temporary').setDescription('Temporary membership?').setRequired(false))
+ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
+ async execute(interaction) {
+ try {
+ const channel = interaction.options.getChannel('channel');
+ const maxAge = interaction.options.getInteger('maxage') || 0;
+ const maxUses = interaction.options.getInteger('maxuses') || 0;
+ const temporary = interaction.options.getBoolean('temporary') || false;
+
+ const targetChannel = channel || interaction.guild.channels.cache.find(c => c.type === 0);
+ if (!targetChannel) {
+ await interaction.reply({ content: 'No valid channel found to create an invite.', ephemeral: true });
+ return;
+ }
+
+ const invite = await targetChannel.createInvite({ maxAge, maxUses, temporary, unique: true });
+
+ const db = readDb();
+ if (!db[interaction.guildId]) db[interaction.guildId] = {};
+ if (!db[interaction.guildId].invites) db[interaction.guildId].invites = [];
+
+ const item = {
+ code: invite.code,
+ url: invite.url,
+ channelId: targetChannel.id,
+ createdAt: new Date().toISOString(),
+ maxUses: invite.maxUses || maxUses || 0,
+ maxAge: invite.maxAge || maxAge || 0,
+ temporary: !!invite.temporary,
+ };
+
+ db[interaction.guildId].invites.push(item);
+ writeDb(db);
+
+ await interaction.reply({ content: `Invite created: ${invite.url}`, ephemeral: true });
+ } catch (error) {
+ console.error('Error in create-invite:', error);
+ await interaction.reply({ content: 'Failed to create invite.', ephemeral: true });
+ }
+ },
+};
diff --git a/discord-bot/commands/list-invites.js b/discord-bot/commands/list-invites.js
new file mode 100644
index 0000000..bcc32e1
--- /dev/null
+++ b/discord-bot/commands/list-invites.js
@@ -0,0 +1,42 @@
+const { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
+const { readDb } = require('../../backend/db');
+
+module.exports = {
+ name: 'list-invites',
+ description: 'List invites created by the bot for this guild',
+ enabled: true,
+ builder: new SlashCommandBuilder()
+ .setName('list-invites')
+ .setDescription('List invites created by the bot for this guild')
+ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
+ async execute(interaction) {
+ try {
+ const db = readDb();
+ const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
+
+ if (!invites.length) {
+ await interaction.reply({ content: 'No invites created by the bot in this server.', ephemeral: true });
+ return;
+ }
+
+ // Build a message with invite details and action buttons
+ for (const inv of invites) {
+ const created = inv.createdAt || 'Unknown';
+ const uses = inv.uses || inv.maxUses || 0;
+ const temporary = inv.temporary ? 'Yes' : 'No';
+ const content = `Invite: ${inv.url}\nCreated: ${created}\nUses: ${uses}\nMax Uses: ${inv.maxUses || 0}\nMax Age (s): ${inv.maxAge || 0}\nTemporary: ${temporary}`;
+
+ const row = new ActionRowBuilder()
+ .addComponents(
+ new ButtonBuilder().setLabel('Copy Invite').setStyle(ButtonStyle.Secondary).setCustomId(`copy_inv_${inv.code}`),
+ new ButtonBuilder().setLabel('Delete Invite').setStyle(ButtonStyle.Danger).setCustomId(`delete_inv_${inv.code}`),
+ );
+
+ await interaction.reply({ content, components: [row], ephemeral: true });
+ }
+ } catch (error) {
+ console.error('Error in list-invites:', error);
+ await interaction.reply({ content: 'Failed to list invites.', ephemeral: true });
+ }
+ },
+};
diff --git a/discord-bot/index.js b/discord-bot/index.js
index c798443..1eb5aa9 100644
--- a/discord-bot/index.js
+++ b/discord-bot/index.js
@@ -15,6 +15,41 @@ commandHandler(client);
eventHandler(client);
client.on('interactionCreate', async interaction => {
+ // Handle button/component interactions for invites
+ if (interaction.isButton && interaction.isButton()) {
+ const id = interaction.customId || '';
+ if (id.startsWith('copy_inv_')) {
+ const code = id.replace('copy_inv_', '');
+ const db = readDb();
+ const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
+ const inv = invites.find(i => i.code === code);
+ if (inv) {
+ await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true });
+ } else {
+ await interaction.reply({ content: 'Invite not found.', ephemeral: true });
+ }
+ } else if (id.startsWith('delete_inv_')) {
+ const code = id.replace('delete_inv_', '');
+ // permission check: admin only
+ const member = interaction.member;
+ if (!member.permissions.has('Administrator')) {
+ await interaction.reply({ content: 'You must be an administrator to delete invites.', ephemeral: true });
+ return;
+ }
+ try {
+ // call backend delete endpoint
+ const fetch = require('node-fetch');
+ const url = `http://localhost:${process.env.PORT || 3002}/api/servers/${interaction.guildId}/invites/${code}`;
+ await fetch(url, { method: 'DELETE' });
+ await interaction.reply({ content: 'Invite deleted.', ephemeral: true });
+ } catch (e) {
+ console.error('Error deleting invite via API:', e);
+ await interaction.reply({ content: 'Failed to delete invite.', ephemeral: true });
+ }
+ }
+ return;
+ }
+
if (!interaction.isCommand()) return;
const command = client.commands.get(interaction.commandName);
diff --git a/frontend/src/components/ServerSettings.js b/frontend/src/components/ServerSettings.js
index 175ef62..83213be 100644
--- a/frontend/src/components/ServerSettings.js
+++ b/frontend/src/components/ServerSettings.js
@@ -6,12 +6,14 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
import ConfirmDialog from './ConfirmDialog';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import DeleteIcon from '@mui/icons-material/Delete';
const ServerSettings = () => {
const { guildId } = useParams();
const navigate = useNavigate();
const location = useLocation();
- const [settings, setSettings] = useState({ pingCommand: false });
+ // settings state removed (not used) to avoid lint warnings
const [isBotInServer, setIsBotInServer] = useState(false);
const [clientId, setClientId] = useState(null);
const [server, setServer] = useState(null);
@@ -23,6 +25,8 @@ const ServerSettings = () => {
roleId: '',
});
const [commandsList, setCommandsList] = useState([]);
+ const [invites, setInvites] = useState([]);
+ const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
const [commandsExpanded, setCommandsExpanded] = useState(false);
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: {
@@ -54,11 +58,8 @@ const ServerSettings = () => {
}
}
- // Fetch settings
- axios.get(`http://localhost:3002/api/servers/${guildId}/settings`)
- .then(response => {
- setSettings(response.data);
- });
+ // Fetch settings (not used directly in this component)
+ axios.get(`http://localhost:3002/api/servers/${guildId}/settings`).catch(() => {});
// Check if bot is in server
axios.get(`http://localhost:3002/api/servers/${guildId}/bot-status`)
@@ -107,6 +108,11 @@ const ServerSettings = () => {
})
.catch(() => setCommandsList([]));
+ // Fetch invites
+ axios.get(`http://localhost:3002/api/servers/${guildId}/invites`)
+ .then(resp => setInvites(resp.data || []))
+ .catch(() => setInvites([]));
+
// Open commands accordion if navigated from Help back button
if (location.state && location.state.openCommands) {
setCommandsExpanded(true);
@@ -188,16 +194,6 @@ const ServerSettings = () => {
return 'custom';
}
- const togglePingCommand = () => {
- const newSettings = { ...settings, pingCommand: !settings.pingCommand };
- axios.post(`http://localhost:3002/api/servers/${guildId}/settings`, newSettings)
- .then(response => {
- if (response.data.success) {
- setSettings(newSettings);
- }
- });
- };
-
const handleInviteBot = () => {
if (!clientId) return;
const permissions = 8; // Administrator
@@ -255,7 +251,7 @@ const ServerSettings = () => {
{!isBotInServer && Invite the bot to enable commands.}
- {commandsList.map(cmd => (
+ {commandsList && [...commandsList].sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'})).map(cmd => (
{cmd.name}
@@ -288,6 +284,87 @@ const ServerSettings = () => {
+ {/* Invite creation and list */}
+
+ }>
+ Invites
+
+
+ {!isBotInServer && Invite features require the bot to be in the server.}
+
+
+ Channel (optional)
+
+
+
+
+
+ Expiry
+
+
+
+
+
+ Max Uses
+
+
+
+
+
+
+ Temporary
+ setInviteForm(f => ({ ...f, temporary: e.target.checked }))} />} label="" />
+
+
+
+
+
+
+
+
+ {invites.length === 0 && No invites created by the bot.}
+ {invites.map(inv => (
+
+
+ {inv.url}
+ Created: {new Date(inv.createdAt).toLocaleString()} • Uses: {inv.uses || 0} • MaxUses: {inv.maxUses || 0} • MaxAge(s): {inv.maxAge || 0} • Temporary: {inv.temporary ? 'Yes' : 'No'}
+
+
+ } onClick={() => { navigator.clipboard.writeText(inv.url); }}>Copy
+ } color="error" onClick={async () => {
+ try {
+ await axios.delete(`http://localhost:3002/api/servers/${guildId}/invites/${inv.code}`);
+ setInvites(prev => prev.filter(i => i.code !== inv.code));
+ } catch (err) { console.error('Error deleting invite:', err); }
+ }}>Delete
+
+
+ ))}
+
+
+
{/* Help moved to dedicated Help page */}
}>
diff --git a/frontend/src/contexts/ThemeContext.js b/frontend/src/contexts/ThemeContext.js
index 224eca6..ed16d19 100644
--- a/frontend/src/contexts/ThemeContext.js
+++ b/frontend/src/contexts/ThemeContext.js
@@ -11,16 +11,25 @@ export const ThemeProvider = ({ children }) => {
const [themeName, setThemeName] = useState(localStorage.getItem('themeName') || 'discord');
useEffect(() => {
+ // Prefer an explicit user selection (stored in localStorage) over defaults or server values.
+ // Behavior:
+ // - If localStorage has a themeName, use that (user's explicit choice always wins).
+ // - Else if the authenticated user has a server-side preference, adopt that and persist it locally.
+ // - Else (first visit, no local choice and no server preference) use default 'discord'.
+ const storedTheme = localStorage.getItem('themeName');
+ if (storedTheme) {
+ setThemeName(storedTheme);
+ return;
+ }
+
if (user && user.theme) {
setThemeName(user.theme);
- } else {
- const storedTheme = localStorage.getItem('themeName');
- if (storedTheme) {
- setThemeName(storedTheme);
- } else {
- setThemeName('discord');
- }
+ localStorage.setItem('themeName', user.theme);
+ return;
}
+
+ // First-time visitor: fall back to default
+ setThemeName('discord');
}, [user]);
const theme = useMemo(() => {