feat: Agregar API para obtener roles de un gremio y recargar roles en el panel de control

This commit is contained in:
Shni
2025-10-15 11:08:40 -05:00
parent 4037569707
commit 51fa42c28a
3 changed files with 188 additions and 16 deletions

View File

@@ -1069,6 +1069,89 @@ export const server = createServer(
}
// Dashboard routes
// Runtime API to fetch roles for a guild (used by client-side fallback)
// GET /api/dashboard/:guildId/roles
if (
url.pathname.startsWith("/api/dashboard/") &&
url.pathname.endsWith("/roles") &&
req.method === "GET"
) {
// path like /api/dashboard/<guildId>/roles
const parts = url.pathname.split("/").filter(Boolean);
// parts[0] === 'api', parts[1] === 'dashboard', parts[2] === '<guildId>', parts[3] === 'roles'
if (parts.length >= 4) {
const gid = parts[2];
const botToken = process.env.DISCORD_BOT_TOKEN ?? process.env.TOKEN;
if (!botToken) {
res.writeHead(
403,
applySecurityHeadersForRequest(req, {
"Content-Type": "application/json",
})
);
res.end(
JSON.stringify({ ok: false, error: "no bot token configured" })
);
return;
}
try {
const rolesRes = await fetch(
`https://discord.com/api/guilds/${encodeURIComponent(
String(gid)
)}/roles`,
{ headers: { Authorization: `Bot ${botToken}` } }
);
if (!rolesRes.ok) {
res.writeHead(
rolesRes.status,
applySecurityHeadersForRequest(req, {
"Content-Type": "application/json",
})
);
res.end(
JSON.stringify({
ok: false,
status: rolesRes.status,
statusText: rolesRes.statusText,
})
);
return;
}
const rolesJson = await rolesRes.json();
const mapped = Array.isArray(rolesJson)
? rolesJson.map((r: any) => ({
id: String(r.id),
name: String(r.name || r.id),
color:
typeof r.color !== "undefined" && r.color !== null
? "#" + ("000000" + r.color.toString(16)).slice(-6)
: r.colorHex || r.hex
? "#" + String(r.colorHex || r.hex).replace(/^#?/, "")
: "#8b95a0",
}))
: [];
res.writeHead(
200,
applySecurityHeadersForRequest(req, {
"Content-Type": "application/json",
})
);
res.end(
JSON.stringify({ ok: true, count: mapped.length, roles: mapped })
);
return;
} catch (err) {
res.writeHead(
500,
applySecurityHeadersForRequest(req, {
"Content-Type": "application/json",
})
);
res.end(JSON.stringify({ ok: false, error: String(err) }));
return;
}
}
}
// Dev-only helper: fetch roles for a guild (requires COLLAB_TEST=1 and DISCORD_BOT_TOKEN)
if (url.pathname.startsWith("/__dev/fetch-roles")) {
if (process.env.COLLAB_TEST !== "1") {
@@ -1230,7 +1313,7 @@ export const server = createServer(
if (url.pathname === "/dashboard" || url.pathname === "/dashboard/") {
// determine whether bot is in each guild (if we have a bot token)
try {
const botToken = process.env.TOKEN; // No existe env var DISCORD_BOT_TOKEN si no TOKEN (compatibilidad)
const botToken = process.env.DISCORD_BOT_TOKEN ?? process.env.TOKEN; // prefer DISCORD_BOT_TOKEN, fallback to TOKEN
if (botToken && Array.isArray(guilds) && guilds.length) {
await Promise.all(
guilds.map(async (g: any) => {
@@ -1295,7 +1378,7 @@ export const server = createServer(
// Attempt to fetch guild roles via Discord Bot API if token available
let guildRoles: Array<{ id: string; name: string }> = [];
try {
const botToken = process.env.TOKEN; // No existe env var DISCORD_BOT_TOKEN si no TOKEN (compatibilidad)
const botToken = process.env.DISCORD_BOT_TOKEN ?? process.env.TOKEN; // prefer DISCORD_BOT_TOKEN, fallback to TOKEN
if (botToken) {
const rolesRes = await fetch(
`https://discord.com/api/guilds/${encodeURIComponent(
@@ -1309,6 +1392,12 @@ export const server = createServer(
guildRoles = rolesJson.map((r: any) => ({
id: String(r.id),
name: String(r.name || r.id),
color:
typeof r.color !== "undefined" && r.color !== null
? "#" + ("000000" + r.color.toString(16)).slice(-6)
: r.colorHex || r.hex
? "#" + String(r.colorHex || r.hex).replace(/^#?/, "")
: "#8b95a0",
}));
// Debug: log number of roles fetched for observability in dev
try {

View File

@@ -18,22 +18,23 @@
<% const selectedStaff = (guildConfig && Array.isArray(guildConfig.staff) ? guildConfig.staff.map(String) : (guildConfig && guildConfig.staff ? String(guildConfig.staff).split(',') : [])) || []; %>
<% if (typeof guildRoles !== 'undefined' && guildRoles && guildRoles.length) { %>
<div class="mb-2">
<div id="staffTagsContainer" class="w-full rounded p-3 flex items-center gap-2 flex-wrap">
<div id="staffChips" class="flex items-center gap-2 flex-wrap"></div>
<button id="openRolePicker" type="button" class="ml-2 self-stretch inline-flex items-center justify-center px-3 py-1 rounded bg-white/6 hover:bg-white/10">
<div id="staffTagsContainer" class="w-full rounded p-3 flex flex-col sm:flex-row items-start sm:items-center gap-2">
<div id="staffChips" class="flex-1 min-w-0 flex items-center gap-2 flex-wrap"></div>
<button id="openRolePicker" type="button" class="ml-0 sm:ml-2 inline-flex items-center justify-center px-3 py-1 rounded bg-white/6 hover:bg-white/10">
<!-- plus svg -->
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14"/></svg>
</button>
<input type="hidden" id="staffHidden" name="staff" value="<%= selectedStaff.join(',') %>" />
</div>
<div class="text-xs text-slate-300 mt-1">Pulsa + para seleccionar roles del servidor.</div>
<!-- Role picker modal (hidden by default) -->
<div id="rolePickerModal" class="fixed inset-0 flex items-center justify-center z-50 hidden">
<div class="absolute inset-0" id="rolePickerBackdrop"></div>
<div class="relative w-full max-w-md bg-gray-800 rounded p-4 shadow-lg glass-card">
<div id="rolePickerModal" class="fixed inset-0 flex items-end sm:items-center justify-center z-50 hidden">
<div class="absolute inset-0 bg-black/50" id="rolePickerBackdrop"></div>
<div class="relative w-full sm:w-auto max-w-md bg-gray-800 rounded-t-lg sm:rounded p-4 shadow-lg glass-card mx-2 sm:mx-0" style="max-height:85vh; overflow:auto;">
<div class="flex items-center justify-between mb-2">
<strong class="text-sm">Seleccionar roles</strong>
<button id="closeRolePicker" type="button" class="text-slate-300">✕</button>
</div>
<input id="rolePickerSearch" class="w-full rounded p-2 bg-transparent border-2 border-dashed mb-2" placeholder="Filtrar roles..." />
<div id="rolePickerList" class="max-h-60 overflow-auto rounded bg-white/4 p-1"></div>
@@ -128,25 +129,59 @@
return { id: String(r.id), name: String(r.name || r.id), color: c };
});
%>
const ROLE_OPTIONS = <%- JSON.stringify(_roles) %>;
let ROLE_OPTIONS = <%- JSON.stringify(_roles) %>;
// Initial selected staff IDs (strings)
const INITIAL_STAFF = <%- JSON.stringify(selectedStaff || []) %>;
let selectedIds = Array.isArray(INITIAL_STAFF) ? INITIAL_STAFF.slice() : [];
// Fallback: if server didn't provide ROLE_OPTIONS (empty), try to read from any
// existing <select> on the page or request roles from server API at runtime.
(async function ensureRoleOptions(){
try {
if (!Array.isArray(ROLE_OPTIONS) || ROLE_OPTIONS.length === 0) {
// try to read an existing select element first
const sel = document.getElementById('staffSelect') || document.querySelector('select[name="staff"]');
if (sel && sel.options && sel.options.length) {
ROLE_OPTIONS = Array.from(sel.options).map(o => ({ id: String(o.value), name: String(o.textContent || o.innerText || o.label || o.value).trim(), color: (o.dataset && o.dataset.color) ? o.dataset.color : '#8b95a0' }));
renderChips();
return;
}
// otherwise, try fetching roles from server at runtime
try {
const guildId = '<%= selectedGuild %>';
if (guildId) {
const resp = await fetch(`/api/dashboard/${encodeURIComponent(guildId)}/roles`, { method: 'GET', headers: { 'Accept': 'application/json' } });
if (resp.ok) {
const j = await resp.json();
if (j && Array.isArray(j.roles) && j.roles.length) {
ROLE_OPTIONS = j.roles.map(r=>({ id: String(r.id), name: String(r.name||r.id), color: r.color || '#8b95a0' }));
renderChips();
return;
}
}
}
} catch (e){ /* ignore fetch errors */ }
}
} catch (e) {
// ignore
}
})();
function renderChips(){
if (!staffChips) return;
staffChips.innerHTML = '';
for(const id of selectedIds){
const role = ROLE_OPTIONS.find(r=>r.id===String(id));
const label = role ? role.name : String(id);
const chip = document.createElement('div');
chip.className = 'px-2 py-1 bg-white/6 rounded-full text-sm flex items-center gap-2';
const swatch = role && role.color ? `<span class="w-3 h-3 rounded-full inline-block" style="background:${role.color}"></span>` : '';
chip.innerHTML = `${swatch}<span class="truncate">${label}</span><button type="button" class="ml-2 text-xs text-slate-300 remove-chip" data-id="${id}">✕</button>`;
const label = role ? role.name : String(id);
const chip = document.createElement('div');
chip.className = 'px-2 py-1 bg-white/6 rounded-full text-sm flex items-center gap-2';
const swatch = role && role.color ? `<span class="w-3 h-3 rounded-full inline-block" style="background:${role.color}"></span>` : '';
chip.innerHTML = `${swatch}<span class="truncate">${label}</span><button type="button" class="ml-2 text-xs text-slate-300 remove-chip" data-id="${id}">✕</button>`;
staffChips.appendChild(chip);
}
// update hidden input
if(staffHidden) staffHidden.value = selectedIds.join(',');
if(typeof staffHidden !== 'undefined' && staffHidden) staffHidden.value = selectedIds.join(',');
}
function showSuggestions(query){
@@ -175,6 +210,17 @@
// initial render of chips
renderChips();
// Listen for runtime roles loaded events (from reload button in sidebar)
window.addEventListener('roles:loaded', (ev)=>{
try{
const data = ev && ev.detail && ev.detail.roles ? ev.detail.roles : null;
if(!data || !Array.isArray(data)) return;
ROLE_OPTIONS = data.map(r=>({ id: String(r.id), name: String(r.name||r.id), color: r.color || '#8b95a0' }));
renderChips();
if(typeof populateRolePicker === 'function') populateRolePicker('');
}catch(e){ /* ignore */ }
});
// Role picker modal logic
const openRolePicker = document.getElementById('openRolePicker');
const rolePickerModal = document.getElementById('rolePickerModal');
@@ -194,12 +240,16 @@
function openPicker(){
if(!rolePickerModal) return;
rolePickerModal.classList.remove('hidden');
// prevent body scroll on mobile when modal open
try{ document.documentElement.style.overflow = 'hidden'; }catch(e){}
populateRolePicker('');
setTimeout(()=>{ if(rolePickerSearch) rolePickerSearch.focus(); },50);
}
function closePicker(){
if(!rolePickerModal) return;
rolePickerModal.classList.add('hidden');
// restore body scroll
try{ document.documentElement.style.overflow = ''; }catch(e){}
if(rolePickerSearch) rolePickerSearch.value = '';
if(rolePickerList) rolePickerList.innerHTML = '';
}
@@ -224,6 +274,13 @@
});
}
// Close modal on Escape globally (only when open)
document.addEventListener('keydown', (ev)=>{
if(ev.key === 'Escape' && rolePickerModal && !rolePickerModal.classList.contains('hidden')){
closePicker();
}
});
if(rolePickerList){
rolePickerList.addEventListener('click', (ev)=>{
const it = ev.target.closest && ev.target.closest('.role-item');

View File

@@ -2,7 +2,33 @@
<nav class="bg-transparent rounded-xl p-4 sticky top-20 h-[80vh] overflow-auto">
<div class="mb-4 flex items-center gap-2">
<a href="/dashboard/<%= selectedGuild %>/overview" class="px-3 py-1 rounded-full bg-white/5 text-white text-xs">Home</a>
<button id="reloadRolesBtn" type="button" title=":" class="px-2 py-1 rounded-full bg-white/6 hover:bg-white/10 text-xs" aria-label="Recargar roles">
<!-- minimal two-dot icon -->
<span aria-hidden="true" class="text-sm">··</span>
</button>
</div>
<script>
(function(){
const btn = document.getElementById('reloadRolesBtn');
if(!btn) return;
btn.addEventListener('click', async function(e){
e.preventDefault();
const guildId = '<%= selectedGuild %>';
if(!guildId) return;
try{
const res = await fetch(`/api/dashboard/${encodeURIComponent(guildId)}/roles`, { method: 'GET', headers: { 'Accept': 'application/json' } });
if(!res.ok) {
return;
}
const j = await res.json();
if(j && Array.isArray(j.roles)){
window.dispatchEvent(new CustomEvent('roles:loaded', { detail: { roles: j.roles } }));
}
}catch(err){ /* ignore errors */ }
});
})();
</script>
<ul class="mt-2 space-y-3 text-slate-200">
<li class="text-sm"><a href="/dashboard/<%= selectedGuild %>/settings" class="flex items-center gap-3"><span class="opacity-80">⚙️</span> General Settings</a></li>
</ul>