Files
2026-02-22 20:27:09 -05:00

440 lines
18 KiB
JavaScript

'use strict';
const express = require('express');
const { CJbot } = require('../model/minecraft');
const database = require('./storage/database');
function createRouter() {
const router = express.Router();
function dbAvailable() {
return database && database.db;
}
router.get('/api/invite/sites', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const sites = await database.getInviteSites();
// Add bot online status
const result = sites.map(s => ({
...s,
players: s.players ? s.players.split(',') : [],
bot_online: !!(CJbot.bots[s.bot_name] && CJbot.bots[s.bot_name].isReady),
}));
res.json({ sites: result });
} catch (error) {
console.error('API Error /api/invite/sites:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/invite/bots', (req, res) => {
try {
const bots = Object.keys(CJbot.bots);
res.json({ bots });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/api/invite/online-players', (req, res) => {
try {
const playerSet = new Set();
for (const bot of Object.values(CJbot.bots)) {
if (bot.isReady && bot.bot && bot.bot.players) {
for (const name of Object.keys(bot.bot.players)) {
playerSet.add(name);
}
}
}
res.json({ players: [...playerSet].sort() });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/api/invite/sites', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const { name, label, bot_name, description } = req.body;
if (!name || !label || !bot_name) {
return res.status(400).json({ error: 'Missing name, label, or bot_name' });
}
await database.addInviteSite(name, label, bot_name, description);
res.json({ status: 'created' });
} catch (error) {
if (error.message && error.message.includes('UNIQUE')) {
return res.status(409).json({ error: 'Site name already exists' });
}
console.error('API Error POST /api/invite/sites:', error);
res.status(500).json({ error: error.message });
}
});
router.put('/api/invite/sites/:id', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const id = parseInt(req.params.id);
await database.updateInviteSite(id, req.body);
res.json({ status: 'updated' });
} catch (error) {
console.error('API Error PUT /api/invite/sites/:id:', error);
res.status(500).json({ error: error.message });
}
});
router.delete('/api/invite/sites/:id', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const id = parseInt(req.params.id);
await database.deleteInviteSite(id);
res.json({ status: 'deleted' });
} catch (error) {
console.error('API Error DELETE /api/invite/sites/:id:', error);
res.status(500).json({ error: error.message });
}
});
router.post('/api/invite/sites/:id/players', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const siteId = parseInt(req.params.id);
const { player_name } = req.body;
if (!player_name) return res.status(400).json({ error: 'Missing player_name' });
await database.addInvitePermission(siteId, player_name);
res.json({ status: 'added' });
} catch (error) {
console.error('API Error POST /api/invite/sites/:id/players:', error);
res.status(500).json({ error: error.message });
}
});
router.delete('/api/invite/sites/:id/players/:player', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const siteId = parseInt(req.params.id);
const playerName = req.params.player;
await database.removeInvitePermission(siteId, playerName);
res.json({ status: 'removed' });
} catch (error) {
console.error('API Error DELETE /api/invite/sites/:id/players/:player:', error);
res.status(500).json({ error: error.message });
}
});
router.post('/api/invite/trigger', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const { siteName, playerName } = req.body;
if (!siteName || !playerName) {
return res.status(400).json({ error: 'Missing siteName or playerName' });
}
const site = await database.getInviteSiteByName(siteName);
if (!site) return res.status(404).json({ error: 'Site not found' });
const allowed = await database.isPlayerAllowedAtSite(siteName, playerName);
if (!allowed) return res.status(403).json({ error: `${playerName} is not allowed at ${siteName}` });
const Invite = require('./invite');
Invite.executeInvite(site.bot_name, playerName)
.catch(err => console.error('Web invite trigger error:', err));
res.json({ status: 'triggered', message: `Invite sent for ${playerName} via ${site.bot_name}` });
} catch (error) {
console.error('API Error POST /api/invite/trigger:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'invites',
tabLabel: 'Invites',
tabOrder: 30,
html: `
<div id="inviteArea">
<div style="padding:20px;color:#6b7280;text-align:center">Loading invite sites...</div>
</div>
`,
css: `
.invite-toolbar{display:flex;gap:8px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
.invite-toolbar button{background:#2563eb;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.85em}
.invite-toolbar button:hover{background:#1d4ed8}
.invite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:16px}
.invite-card{background:#1f2937;border:1px solid #374151;border-radius:8px;padding:16px;transition:border-color .2s}
.invite-card:hover{border-color:#60a5fa}
.invite-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.invite-card-header h3{font-size:1em;color:#60a5fa;display:flex;align-items:center;gap:8px}
.invite-card-header .actions{display:flex;gap:4px}
.invite-card-header .actions button{background:none;border:1px solid #374151;color:#9ca3af;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:.75em}
.invite-card-header .actions button:hover{border-color:#60a5fa;color:#60a5fa}
.invite-card-header .actions button.del:hover{border-color:#ef4444;color:#ef4444}
.invite-meta{font-size:.8em;color:#9ca3af;margin-bottom:10px}
.invite-meta span{margin-right:12px}
.invite-players{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px;min-height:28px}
.player-chip{background:#1e3a5f;color:#60a5fa;padding:3px 8px;border-radius:12px;font-size:.8em;display:flex;align-items:center;gap:4px}
.player-chip .remove{background:none;border:none;color:#ef4444;cursor:pointer;font-size:.9em;padding:0 2px;line-height:1}
.player-chip .remove:hover{color:#f87171}
.invite-add-player{display:flex;gap:4px;margin-bottom:10px}
.invite-add-player input{flex:1;padding:6px 8px;border:1px solid #374151;border-radius:4px;background:#111827;color:#e5e7eb;font-size:.8em}
.invite-add-player button{background:#059669;color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:.8em}
.invite-add-player button:hover{background:#047857}
.invite-trigger{display:flex;gap:4px;align-items:center}
.invite-trigger input{flex:1;padding:6px 8px;border:1px solid #374151;border-radius:4px;background:#111827;color:#e5e7eb;font-size:.8em}
.invite-trigger button{background:#7c3aed;color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:.8em}
.invite-trigger button:hover{background:#6d28d9}
.invite-trigger-status{font-size:.8em;min-height:16px;margin-top:4px}
.invite-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:100;display:flex;align-items:center;justify-content:center}
.invite-modal{background:#1f2937;border:1px solid #374151;border-radius:8px;padding:24px;width:400px;max-width:90vw}
.invite-modal h3{color:#60a5fa;margin-bottom:16px}
.invite-modal label{display:block;font-size:.85em;color:#9ca3af;margin-bottom:4px;margin-top:12px}
.invite-modal input,.invite-modal select,.invite-modal textarea{width:100%;padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em;box-sizing:border-box}
.invite-modal textarea{resize:vertical;min-height:60px}
.invite-modal .modal-actions{display:flex;gap:8px;margin-top:20px;justify-content:flex-end}
.invite-modal .modal-actions button{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.85em;border:none}
.invite-modal .modal-actions .btn-save{background:#2563eb;color:#fff}
.invite-modal .modal-actions .btn-save:hover{background:#1d4ed8}
.invite-modal .modal-actions .btn-cancel{background:#374151;color:#e5e7eb}
.invite-modal .modal-actions .btn-cancel:hover{background:#4b5563}
.invite-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
`,
onTabActive: 'onInviteTabActive',
js: `
let inviteSites=[], inviteBots=[], inviteLoaded=false, inviteOnlinePlayers=[];
function onInviteTabActive() {
if (!inviteLoaded) loadInviteSites();
}
async function loadInviteSites() {
try {
const [sitesRes, botsRes, playersRes] = await Promise.all([
fetch('/api/invite/sites'),
fetch('/api/invite/bots'),
fetch('/api/invite/online-players')
]);
if (!sitesRes.ok || !botsRes.ok) {
document.getElementById('inviteArea').innerHTML='<div class="invite-empty">Failed to load invite data</div>';
return;
}
const sitesData = await sitesRes.json();
const botsData = await botsRes.json();
inviteSites = sitesData.sites || [];
inviteBots = botsData.bots || [];
if (playersRes.ok) {
const playersData = await playersRes.json();
inviteOnlinePlayers = playersData.players || [];
}
inviteLoaded = true;
renderInviteSites();
} catch(e) {
document.getElementById('inviteArea').innerHTML='<div class="invite-empty">Failed to load invite data</div>';
}
}
function renderInviteSites() {
const area = document.getElementById('inviteArea');
let html = '<div class="invite-toolbar">' +
'<button onclick="showInviteModal()">+ Add Site</button>' +
'<button onclick="loadInviteSites()" style="background:#374151">Refresh</button>' +
'</div>';
if (inviteSites.length === 0) {
html += '<div class="invite-empty">No invite sites configured</div>';
area.innerHTML = html;
return;
}
html += '<div class="invite-grid">';
for (const site of inviteSites) {
const statusCls = site.bot_online ? 'online' : 'offline';
const playerChips = (site.players || []).map(p =>
'<span class="player-chip">' + escHtml(p) +
' <button class="remove" onclick="removeInvitePlayer(' + site.id + ',\\'' + escHtml(p) + '\\')">&times;</button>' +
'</span>'
).join('');
html += '<div class="invite-card">' +
'<div class="invite-card-header">' +
'<h3><span class="bot-status ' + statusCls + '"></span> ' + escHtml(site.label) + ' (' + escHtml(site.name) + ')</h3>' +
'<div class="actions">' +
'<button onclick="showInviteModal(' + site.id + ')">Edit</button>' +
'<button class="del" onclick="deleteInviteSite(' + site.id + ',\\'' + escHtml(site.name) + '\\')">Delete</button>' +
'</div>' +
'</div>' +
'<div class="invite-meta">' +
'<span>Bot: <strong>' + escHtml(site.bot_name) + '</strong></span>' +
(site.description ? '<span>' + escHtml(site.description) + '</span>' : '') +
'</div>' +
'<div class="invite-players">' + (playerChips || '<span style="color:#6b7280;font-size:.8em">No players</span>') + '</div>' +
'<div class="invite-add-player">' +
'<div class="ac-wrap" style="flex:1">' +
'<input type="text" placeholder="Player name" id="inv-add-' + site.id + '" autocomplete="off" onkeydown="if(event.key===\\'Enter\\')addInvitePlayer(' + site.id + ')">' +
'<div class="ac-list" id="ac-inv-add-' + site.id + '"></div>' +
'</div>' +
'<button onclick="addInvitePlayer(' + site.id + ')">Add</button>' +
'</div>' +
'<div class="invite-trigger">' +
'<div class="ac-wrap" style="flex:1">' +
'<input type="text" placeholder="Player to invite" id="inv-trig-' + site.id + '" autocomplete="off">' +
'<div class="ac-list" id="ac-inv-trig-' + site.id + '"></div>' +
'</div>' +
'<button onclick="triggerInvite(\\'' + escHtml(site.name) + '\\',' + site.id + ')">Invite</button>' +
'</div>' +
'<div class="invite-trigger-status" id="inv-status-' + site.id + '"></div>' +
'</div>';
}
html += '</div>';
area.innerHTML = html;
// Wire up autocomplete on player inputs
for (const site of inviteSites) {
setupAC('inv-add-' + site.id, 'ac-inv-add-' + site.id,
q => {
const lower = q.toLowerCase();
return inviteOnlinePlayers
.filter(p => !lower || p.toLowerCase().includes(lower))
.map(p => ({label: p, value: p}));
}
);
setupAC('inv-trig-' + site.id, 'ac-inv-trig-' + site.id,
q => {
const lower = q.toLowerCase();
return inviteOnlinePlayers
.filter(p => !lower || p.toLowerCase().includes(lower))
.map(p => ({label: p, value: p}));
}
);
}
}
function showInviteModal(editId) {
const site = editId ? inviteSites.find(s => s.id === editId) : null;
const title = site ? 'Edit Site' : 'Add Site';
const botOptions = inviteBots.map(b =>
'<option value="' + escHtml(b) + '"' + (site && site.bot_name === b ? ' selected' : '') + '>' + escHtml(b) + '</option>'
).join('');
const overlay = document.createElement('div');
overlay.className = 'invite-modal-overlay';
overlay.id = 'inviteModalOverlay';
overlay.innerHTML = '<div class="invite-modal">' +
'<h3>' + title + '</h3>' +
'<label>Short Name (key)</label>' +
'<input type="text" id="invModalName" value="' + (site ? escHtml(site.name) : '') + '">' +
'<label>Display Label</label>' +
'<input type="text" id="invModalLabel" value="' + (site ? escHtml(site.label) : '') + '">' +
'<label>Bot</label>' +
'<select id="invModalBot">' + botOptions + '</select>' +
'<label>Description</label>' +
'<textarea id="invModalDesc">' + (site ? escHtml(site.description || '') : '') + '</textarea>' +
'<div class="modal-actions">' +
'<button class="btn-cancel" onclick="closeInviteModal()">Cancel</button>' +
'<button class="btn-save" onclick="saveInviteSite(' + (editId || 'null') + ')">Save</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
overlay.addEventListener('click', e => { if (e.target === overlay) closeInviteModal(); });
}
function closeInviteModal() {
const el = document.getElementById('inviteModalOverlay');
if (el) el.remove();
}
async function saveInviteSite(editId) {
const name = document.getElementById('invModalName').value.trim();
const label = document.getElementById('invModalLabel').value.trim();
const bot_name = document.getElementById('invModalBot').value;
const description = document.getElementById('invModalDesc').value.trim();
if (!name || !label || !bot_name) { showToast('Fill in name, label, and bot', 'warning'); return; }
try {
let r;
if (editId) {
r = await fetch('/api/invite/sites/' + editId, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, label, bot_name, description })
});
} else {
r = await fetch('/api/invite/sites', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, label, bot_name, description })
});
}
const d = await r.json();
if (!r.ok) { showToast(d.error || 'Failed', 'error'); return; }
closeInviteModal();
showToast('Site saved', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function deleteInviteSite(id, name) {
if (!confirm('Delete site "' + name + '"? This removes all permissions too.')) return;
try {
const r = await fetch('/api/invite/sites/' + id, { method: 'DELETE' });
if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed', 'error'); return; }
showToast('Site deleted', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function addInvitePlayer(siteId) {
const input = document.getElementById('inv-add-' + siteId);
const name = input.value.trim();
if (!name) return;
try {
const r = await fetch('/api/invite/sites/' + siteId + '/players', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ player_name: name })
});
if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed', 'error'); return; }
input.value = '';
showToast('Player added', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function removeInvitePlayer(siteId, playerName) {
try {
const r = await fetch('/api/invite/sites/' + siteId + '/players/' + encodeURIComponent(playerName), { method: 'DELETE' });
if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed', 'error'); return; }
showToast('Player removed', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function triggerInvite(siteName, siteId) {
const input = document.getElementById('inv-trig-' + siteId);
const statusEl = document.getElementById('inv-status-' + siteId);
const playerName = input.value.trim();
if (!playerName) { statusEl.textContent = 'Enter player name'; statusEl.style.color = '#ef4444'; return; }
try {
statusEl.textContent = 'Sending invite...';
statusEl.style.color = '#9ca3af';
const r = await fetch('/api/invite/trigger', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ siteName, playerName })
});
const d = await r.json();
statusEl.textContent = r.ok ? (d.message || 'Triggered') : (d.error || 'Failed');
statusEl.style.color = r.ok ? '#10b981' : '#ef4444';
} catch(e) {
statusEl.textContent = 'Network error';
statusEl.style.color = '#ef4444';
}
}
`,
};
module.exports = { name: 'Invite', createRouter, webUI };