fixed themes and ui added new features

This commit is contained in:
2025-10-04 08:39:54 -04:00
parent 0b1a6cdea4
commit 834e77a93e
7 changed files with 373 additions and 24 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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 });
}
},
};

View File

@@ -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 });
}
},
};

View File

@@ -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);

View File

@@ -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 = () => {
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '10px' }}>
{commandsList.map(cmd => (
{commandsList && [...commandsList].sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'})).map(cmd => (
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
@@ -288,6 +284,87 @@ const ServerSettings = () => {
</Box>
</AccordionDetails>
</Accordion>
{/* Invite creation and list */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Invites</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite features require the bot to be in the server.</Typography>}
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' }, marginTop: 1 }}>
<Box sx={{ width: { xs: '100%', sm: '40%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Channel (optional)</Typography>
<FormControl fullWidth>
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
<MenuItem value="">(Any channel)</MenuItem>
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
</Select>
</FormControl>
</Box>
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Expiry</Typography>
<FormControl fullWidth>
<Select value={inviteForm.maxAge} onChange={(e) => setInviteForm(f => ({ ...f, maxAge: Number(e.target.value) }))}>
<MenuItem value={0}>Never expire</MenuItem>
<MenuItem value={3600}>1 hour</MenuItem>
<MenuItem value={86400}>1 day</MenuItem>
<MenuItem value={604800}>7 days</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Max Uses</Typography>
<FormControl fullWidth>
<Select value={inviteForm.maxUses} onChange={(e) => setInviteForm(f => ({ ...f, maxUses: Number(e.target.value) }))}>
<MenuItem value={0}>Unlimited</MenuItem>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={5}>5</MenuItem>
<MenuItem value={10}>10</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: { xs: '100%', sm: '20%' } }}>
<Box sx={{ width: '100%' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Temporary</Typography>
<FormControlLabel control={<Switch checked={inviteForm.temporary} onChange={(e) => setInviteForm(f => ({ ...f, temporary: e.target.checked }))} />} label="" />
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<Button variant="contained" onClick={async () => {
try {
const resp = await axios.post(`http://localhost:3002/api/servers/${guildId}/invites`, inviteForm);
if (resp.data && resp.data.success) {
setInvites(prev => [...prev, resp.data.invite]);
}
} catch (err) {
console.error('Error creating invite:', err);
}
}} disabled={!isBotInServer}>Create Invite</Button>
</Box>
</Box>
<Box sx={{ marginTop: 2 }}>
{invites.length === 0 && <Typography>No invites created by the bot.</Typography>}
{invites.map(inv => (
<Box key={inv.code} sx={{ border: '1px solid #eee', borderRadius: 1, padding: 1, marginTop: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography>{inv.url}</Typography>
<Typography variant="caption">Created: {new Date(inv.createdAt).toLocaleString()} Uses: {inv.uses || 0} MaxUses: {inv.maxUses || 0} MaxAge(s): {inv.maxAge || 0} Temporary: {inv.temporary ? 'Yes' : 'No'}</Typography>
</Box>
<Box>
<Button startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard.writeText(inv.url); }}>Copy</Button>
<Button startIcon={<DeleteIcon />} 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</Button>
</Box>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
{/* Help moved to dedicated Help page */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>

View File

@@ -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(() => {