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'} + + + + + + + ))} + + + {/* 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(() => {