feat: Tái cấu trúc bot sang kiến trúc cog, thêm hỗ trợ đa máy chủ, giới thiệu tính năng đăng ký bóng đá, giao diện web và quản lý cấu hình.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Virtus Bot Admin</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand-header">
|
||||
<div class="brand-logo">🛡️</div>
|
||||
<div>
|
||||
<h1>Virtus Bot</h1>
|
||||
<span>Admin Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section-title">SERVERS</div>
|
||||
|
||||
<ul id="serverList" class="server-list">
|
||||
<!-- Server items loaded here -->
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<header class="top-bar">
|
||||
<h2 id="selectedGuildName">Select a Server</h2>
|
||||
<div class="user-profile">
|
||||
<!-- Placeholder for logged in admin info if needed -->
|
||||
<span>Administrator</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loadingOverlay" class="loading-overlay hidden">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading data...</p>
|
||||
</div>
|
||||
|
||||
<div id="contentArea" class="content-area hidden">
|
||||
<!-- TABS -->
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="general">Info</button>
|
||||
<button class="tab-btn" data-tab="config">Config & Admin</button>
|
||||
<button class="tab-btn" data-tab="services">Services</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB CONTENTS -->
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- 1. INFO TAB -->
|
||||
<div id="general" class="tab-pane active">
|
||||
<section class="card">
|
||||
<h3>Server Information</h3>
|
||||
<div id="serverInfoContainer" class="server-info-grid">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 2. CONFIG TAB -->
|
||||
<div id="config" class="tab-pane">
|
||||
<section class="card">
|
||||
<h3>Admin Management</h3>
|
||||
<p class="text-muted">Manage bot administrators for this server.</p>
|
||||
<div class="form-group">
|
||||
<label for="adminIdsInput">Admin IDs (comma separated)</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="adminIdsInput" placeholder="e.g. 123456789, 987654321">
|
||||
<button id="saveAdminsBtn" class="btn primary">Save & Verify</button>
|
||||
</div>
|
||||
<small id="adminValidationMsg"></small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Custom Configurations</h3>
|
||||
<p class="text-muted">Manually add key-value configurations.</p>
|
||||
<form id="configForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>Key</label>
|
||||
<input type="text" id="key" name="key" required placeholder="CONFIG_KEY">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>Value</label>
|
||||
<input type="text" id="value" name="value" required placeholder="Value">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" id="description" name="description"
|
||||
placeholder="Optional description">
|
||||
</div>
|
||||
<button type="submit" class="btn primary">Add Config</button>
|
||||
</form>
|
||||
|
||||
<h4>Current List</h4>
|
||||
<div class="table-container">
|
||||
<table id="configTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Description</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 3. SERVICES TAB -->
|
||||
<div id="services" class="tab-pane">
|
||||
<div id="servicesContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,612 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- State ---
|
||||
let currentGuildId = null;
|
||||
let guilds = [];
|
||||
let currentFeatures = [];
|
||||
let currentConfigs = [];
|
||||
|
||||
// --- Config Mapping ---
|
||||
// Mapping config keys to services and validating them if needed
|
||||
// --- Config Mapping ---
|
||||
// Mapping config keys to services and validating them if needed
|
||||
const SERVICE_CONFIG_MAP = {
|
||||
'home_debt': {
|
||||
keys: ['CHANNEL_HOME_DEBT_ID'],
|
||||
validation: 'channel_list'
|
||||
},
|
||||
'noi_tu': {
|
||||
keys: ['CHANNEL_NOI_TU_IDS'],
|
||||
validation: 'channel_list'
|
||||
},
|
||||
'football': {
|
||||
keys: ['CHANNEL_FOOTBALL_IDS', 'FOOTBALL_API_KEY', 'FOOTBALL_LEAGUES', 'FOOTBALL_TEAMS'],
|
||||
validation: 'channel_list',
|
||||
meta: {
|
||||
'FOOTBALL_LEAGUES': { type: 'multi-select', options: ['Premier League', 'La Liga', 'Serie A', 'Bundesliga', 'Ligue 1', 'UEFA Champions League', 'V-League'] },
|
||||
'FOOTBALL_TEAMS': { type: 'async-select', placeholder: 'Search team...' }
|
||||
}
|
||||
},
|
||||
'score': {
|
||||
keys: [],
|
||||
validation: null
|
||||
}
|
||||
};
|
||||
|
||||
// --- Elements ---
|
||||
const serverList = document.getElementById('serverList');
|
||||
const selectedGuildName = document.getElementById('selectedGuildName');
|
||||
const contentArea = document.getElementById('contentArea');
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
const adminIdsInput = document.getElementById('adminIdsInput');
|
||||
const saveAdminsBtn = document.getElementById('saveAdminsBtn');
|
||||
const adminValidationMsg = document.getElementById('adminValidationMsg');
|
||||
const servicesContainer = document.getElementById('servicesContainer');
|
||||
const serverInfoContainer = document.getElementById('serverInfoContainer');
|
||||
const configTableBody = document.querySelector('#configTable tbody');
|
||||
const configForm = document.getElementById('configForm');
|
||||
|
||||
// --- Init ---
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
await fetchGuilds();
|
||||
setupTabs();
|
||||
setupEventListeners();
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Admin Save
|
||||
saveAdminsBtn.addEventListener('click', saveAdmins);
|
||||
|
||||
// Custom Config Save
|
||||
configForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(configForm);
|
||||
await saveConfig(
|
||||
formData.get('key'),
|
||||
formData.get('value'),
|
||||
formData.get('description')
|
||||
);
|
||||
configForm.reset();
|
||||
refreshData();
|
||||
});
|
||||
}
|
||||
|
||||
function setupTabs() {
|
||||
const tabs = document.querySelectorAll('.tab-btn');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
// UI Toggle
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
|
||||
tab.classList.add('active');
|
||||
document.getElementById(tab.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- API and Logic ---
|
||||
|
||||
function showLoading(show) {
|
||||
if (show) {
|
||||
loadingOverlay.classList.remove('hidden');
|
||||
contentArea.style.opacity = '0.5';
|
||||
contentArea.style.pointerEvents = 'none';
|
||||
} else {
|
||||
loadingOverlay.classList.add('hidden');
|
||||
contentArea.style.opacity = '1';
|
||||
contentArea.style.pointerEvents = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGuilds() {
|
||||
try {
|
||||
const res = await fetch('/api/guilds');
|
||||
guilds = await res.json();
|
||||
renderSidebar();
|
||||
|
||||
// Auto-select first
|
||||
if (guilds.length > 0) {
|
||||
selectGuild(guilds[0].id);
|
||||
} else {
|
||||
serverList.innerHTML = '<li style="padding:15px">No guilds found. Invite bot to server first.</li>';
|
||||
selectedGuildName.textContent = 'No Servers Available';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
serverList.innerHTML = '<li style="padding:15px; color:var(--danger-color);">Failed to load servers</li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Modified refreshData to accept silent mode
|
||||
async function refreshData(silent = false) {
|
||||
if (!currentGuildId) return;
|
||||
|
||||
if (!silent) showLoading(true);
|
||||
|
||||
try {
|
||||
// Parallel fetch
|
||||
const [featuresRes, configsRes, detailsRes] = await Promise.all([
|
||||
fetch(`/api/guilds/${currentGuildId}/features`),
|
||||
fetch(`/api/guilds/${currentGuildId}/config`),
|
||||
fetch(`/api/guilds/${currentGuildId}/details`)
|
||||
]);
|
||||
|
||||
currentFeatures = await featuresRes.json();
|
||||
currentConfigs = await configsRes.json();
|
||||
const details = await detailsRes.json();
|
||||
|
||||
renderServerInfo(details);
|
||||
renderAdminSection();
|
||||
renderServicesTab();
|
||||
renderConfigTab();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to load guild data.");
|
||||
} finally {
|
||||
if (!silent) showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(key, value, description) {
|
||||
try {
|
||||
const res = await fetch(`/api/guilds/${currentGuildId}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value, description, guild_id: currentGuildId })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
return true;
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
function renderSidebar() {
|
||||
serverList.innerHTML = '';
|
||||
guilds.forEach(guild => {
|
||||
const li = document.createElement('li');
|
||||
li.className = `server-item ${String(guild.id) === currentGuildId ? 'active' : ''}`;
|
||||
li.textContent = guild.name;
|
||||
li.onclick = () => selectGuild(guild.id);
|
||||
serverList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function selectGuild(id) {
|
||||
currentGuildId = String(id);
|
||||
const guild = guilds.find(g => String(g.id) === currentGuildId);
|
||||
selectedGuildName.textContent = guild ? guild.name : 'Unknown Guild';
|
||||
contentArea.classList.remove('hidden');
|
||||
|
||||
// Re-render sidebar to update active state
|
||||
renderSidebar();
|
||||
|
||||
// Load Data
|
||||
refreshData(); // Not silent (full load)
|
||||
}
|
||||
|
||||
// --- Tab 1: Server Info ---
|
||||
function renderServerInfo(details) {
|
||||
if (!details.found) {
|
||||
serverInfoContainer.innerHTML = '<p>Guild details not found in Bot cache.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const iconHtml = details.icon_url
|
||||
? `<img src="${details.icon_url}" style="width:64px; height:64px; border-radius:50%; margin-bottom:10px;">`
|
||||
: `<div style="width:64px; height:64px; background:#444; border-radius:50%; margin:0 auto 10px; display:flex; align-items:center; justify-content:center;">?</div>`;
|
||||
|
||||
serverInfoContainer.innerHTML = `
|
||||
<div class="info-item">
|
||||
${iconHtml}
|
||||
<div style="font-weight:bold">${details.name}</div>
|
||||
<div class="text-muted">ID: ${details.id}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Members</label>
|
||||
<span>${details.member_count}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Owner ID</label>
|
||||
<span>${details.owner}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Tab 2: Config & Admin ---
|
||||
function renderAdminSection() {
|
||||
const adminConfig = currentConfigs.find(c => c.key === 'ADMIN_IDS');
|
||||
adminIdsInput.value = adminConfig ? adminConfig.value : '';
|
||||
adminValidationMsg.textContent = '';
|
||||
}
|
||||
|
||||
async function saveAdmins() {
|
||||
const raw = adminIdsInput.value;
|
||||
const ids = raw.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
|
||||
// Inline Loading State
|
||||
const originalText = saveAdminsBtn.textContent;
|
||||
saveAdminsBtn.disabled = true;
|
||||
saveAdminsBtn.innerHTML = '<span class="spinner-sm"></span> Verifying...';
|
||||
adminValidationMsg.textContent = 'Checking IDs...';
|
||||
|
||||
let invalidIds = [];
|
||||
|
||||
// Note: For "System/Global" (ID 0), we can't really validate members unless we pick a random guild or check global cache?
|
||||
// Actually, bot.get_user() is safer for global, but endpoints use guild.get_member().
|
||||
// Let's skip validation for ID 0 for now or assume it passes.
|
||||
if (currentGuildId !== "0") {
|
||||
for (const uid of ids) {
|
||||
try {
|
||||
const res = await fetch(`/api/guilds/${currentGuildId}/members/${uid}`);
|
||||
if (!res.ok) invalidIds.push(uid);
|
||||
} catch (e) {
|
||||
invalidIds.push(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidIds.length > 0) {
|
||||
adminValidationMsg.innerHTML = `<span style="color:var(--danger-color)">Invalid User IDs: ${invalidIds.join(', ')}</span>`;
|
||||
saveAdminsBtn.disabled = false;
|
||||
saveAdminsBtn.innerHTML = originalText;
|
||||
return;
|
||||
}
|
||||
|
||||
// All valid
|
||||
await saveConfig('ADMIN_IDS', ids.join(','), 'Admin List');
|
||||
adminValidationMsg.innerHTML = '<span style="color:var(--accent-color)">✅ Saved successfully!</span>';
|
||||
saveAdminsBtn.disabled = false;
|
||||
saveAdminsBtn.innerHTML = originalText;
|
||||
refreshData(true); // Silent refresh
|
||||
}
|
||||
|
||||
// --- Tab 3: Services ---
|
||||
function renderServicesTab() {
|
||||
servicesContainer.innerHTML = '';
|
||||
const knownServices = ['home_debt', 'noi_tu', 'score', 'football'];
|
||||
|
||||
knownServices.forEach(serviceName => {
|
||||
const feature = currentFeatures.find(f => f.feature_name === serviceName);
|
||||
const isEnabled = feature ? feature.is_enabled : false;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'service-card';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'service-header';
|
||||
header.innerHTML = `<h3>${formatName(serviceName)}</h3>`;
|
||||
|
||||
// Toggle
|
||||
const label = document.createElement('label');
|
||||
label.className = 'switch';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = isEnabled;
|
||||
input.onchange = () => toggleService(serviceName, input.checked);
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'slider';
|
||||
|
||||
label.appendChild(input);
|
||||
label.appendChild(span);
|
||||
header.appendChild(label);
|
||||
|
||||
// Body with Configs
|
||||
const body = document.createElement('div');
|
||||
body.className = `service-body ${isEnabled ? '' : 'disabled'}`;
|
||||
|
||||
const configMeta = SERVICE_CONFIG_MAP[serviceName];
|
||||
const relevantKeys = configMeta ? configMeta.keys : [];
|
||||
|
||||
if (relevantKeys.length > 0) {
|
||||
relevantKeys.forEach(key => {
|
||||
const config = currentConfigs.find(c => c.key === key) || { value: '' };
|
||||
const formGroup = document.createElement('div');
|
||||
formGroup.className = 'form-group';
|
||||
|
||||
const meta = configMeta.meta && configMeta.meta[key] ? configMeta.meta[key] : {};
|
||||
const inputType = meta.type || 'text';
|
||||
|
||||
formGroup.innerHTML = `<label>${key}</label>`;
|
||||
|
||||
// Input Container
|
||||
const inputContainer = document.createElement('div');
|
||||
inputContainer.style.position = 'relative'; // For dropdowns
|
||||
|
||||
if (inputType === 'multi-select') {
|
||||
// Options Select
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Available Options (Filtered by what's not selected?)
|
||||
// Actually simpler: Just a select box to Add, and a list of Tags for current values
|
||||
const currentValues = config.value ? config.value.split(',').map(s => s.trim()).filter(s => s) : [];
|
||||
|
||||
const tagsDiv = document.createElement('div');
|
||||
tagsDiv.className = 'tags-container';
|
||||
tagsDiv.id = `tags-${key}`;
|
||||
renderTags(tagsDiv, currentValues, key);
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.style.width = '100%';
|
||||
select.style.marginBottom = '5px';
|
||||
select.innerHTML = '<option value="">+ Add League...</option>';
|
||||
meta.options.forEach(opt => {
|
||||
if (!currentValues.includes(opt)) {
|
||||
const o = document.createElement('option');
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
select.appendChild(o);
|
||||
}
|
||||
});
|
||||
|
||||
select.onchange = () => {
|
||||
if (select.value) {
|
||||
addTag(key, select.value);
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(tagsDiv);
|
||||
container.appendChild(select);
|
||||
inputContainer.appendChild(container);
|
||||
|
||||
// Hidden input to store actual csv value for saving logic (optional, or we rebuild it)
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.id = `input-${key}`;
|
||||
hidden.value = config.value;
|
||||
inputContainer.appendChild(hidden);
|
||||
|
||||
} else if (inputType === 'async-select') {
|
||||
// Search & Add
|
||||
const container = document.createElement('div');
|
||||
|
||||
const currentValues = config.value ? config.value.split(',').map(s => s.trim()).filter(s => s) : [];
|
||||
|
||||
const tagsDiv = document.createElement('div');
|
||||
tagsDiv.className = 'tags-container';
|
||||
tagsDiv.id = `tags-${key}`;
|
||||
renderTags(tagsDiv, currentValues, key);
|
||||
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'text';
|
||||
searchInput.placeholder = meta.placeholder || 'Type to search...';
|
||||
searchInput.style.marginBottom = '0';
|
||||
|
||||
const resultsDiv = document.createElement('div');
|
||||
resultsDiv.className = 'dropdown-results';
|
||||
|
||||
let debounceTimer;
|
||||
searchInput.oninput = (e) => {
|
||||
const val = e.target.value;
|
||||
clearTimeout(debounceTimer);
|
||||
if (val.length < 2) {
|
||||
resultsDiv.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const res = await fetch(`/api/guilds/${currentGuildId}/football/teams?query=${encodeURIComponent(val)}`);
|
||||
const data = await res.json();
|
||||
|
||||
resultsDiv.innerHTML = '';
|
||||
if (data.length > 0) {
|
||||
data.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dropdown-item';
|
||||
div.textContent = item.name; // Could use flag/logo if available
|
||||
div.onclick = () => {
|
||||
addTag(key, item.name); // Storing Name for now as per schema
|
||||
searchInput.value = '';
|
||||
resultsDiv.classList.remove('show');
|
||||
};
|
||||
resultsDiv.appendChild(div);
|
||||
});
|
||||
resultsDiv.classList.add('show');
|
||||
} else {
|
||||
resultsDiv.classList.remove('show');
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
};
|
||||
|
||||
// Hide dropdown on click outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!container.contains(e.target)) resultsDiv.classList.remove('show');
|
||||
});
|
||||
|
||||
container.appendChild(tagsDiv);
|
||||
container.appendChild(searchInput);
|
||||
container.appendChild(resultsDiv);
|
||||
inputContainer.appendChild(container);
|
||||
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.id = `input-${key}`;
|
||||
hidden.value = config.value;
|
||||
inputContainer.appendChild(hidden);
|
||||
|
||||
} else {
|
||||
// Standard Input
|
||||
inputContainer.innerHTML = `
|
||||
<div style="display:flex; gap:10px;">
|
||||
<input type="text" value="${config.value}" id="input-${key}" style="flex:1">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
formGroup.appendChild(inputContainer);
|
||||
|
||||
// Save Button (Common)
|
||||
const saveRow = document.createElement('div');
|
||||
saveRow.style.marginTop = '5px';
|
||||
saveRow.innerHTML = `
|
||||
<button class="btn secondary" id="btn-${key}" onclick="window.saveServiceConfig('${key}', '${configMeta.validation}')">Save</button>
|
||||
<small id="msg-${key}" style="margin-left:10px"></small>
|
||||
`;
|
||||
formGroup.appendChild(saveRow);
|
||||
|
||||
body.appendChild(formGroup);
|
||||
});
|
||||
} else {
|
||||
body.innerHTML = '<p class="text-muted">No specific configurations for this service.</p>';
|
||||
}
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(body);
|
||||
servicesContainer.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers for Tags ---
|
||||
function renderTags(container, values, key) {
|
||||
container.innerHTML = '';
|
||||
values.forEach(val => {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'tag';
|
||||
t.innerHTML = `${val} <span class="remove" onclick="removeTag('${key}', '${val}')">×</span>`;
|
||||
container.appendChild(t);
|
||||
});
|
||||
}
|
||||
|
||||
window.addTag = (key, value) => {
|
||||
const input = document.getElementById(`input-${key}`);
|
||||
let current = input.value ? input.value.split(',').map(s => s.trim()).filter(s => s) : [];
|
||||
if (!current.includes(value)) {
|
||||
current.push(value);
|
||||
input.value = current.join(',');
|
||||
// Re-render
|
||||
const tagsDiv = document.getElementById(`tags-${key}`);
|
||||
renderTags(tagsDiv, current, key);
|
||||
|
||||
// Auto-save? user usually expects "Add" then "Save". Let's stick to explicit Save button logic for consistency.
|
||||
// Or trigger a "dirty" state.
|
||||
}
|
||||
};
|
||||
|
||||
window.removeTag = (key, value) => {
|
||||
const input = document.getElementById(`input-${key}`);
|
||||
let current = input.value ? input.value.split(',').map(s => s.trim()).filter(s => s) : [];
|
||||
current = current.filter(v => v !== value);
|
||||
input.value = current.join(',');
|
||||
|
||||
const tagsDiv = document.getElementById(`tags-${key}`);
|
||||
renderTags(tagsDiv, current, key);
|
||||
};
|
||||
|
||||
async function toggleService(name, enabled) {
|
||||
try {
|
||||
const res = await fetch(`/api/guilds/${currentGuildId}/features`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feature_name: name, is_enabled: enabled })
|
||||
});
|
||||
if (res.ok) refreshData(true); // Silent refresh
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
// Helper for inline onclick
|
||||
window.saveServiceConfig = async (key, validationType) => {
|
||||
const input = document.getElementById(`input-${key}`);
|
||||
const btn = document.getElementById(`btn-${key}`);
|
||||
const msg = document.getElementById(`msg-${key}`);
|
||||
if (!input) return;
|
||||
|
||||
const val = input.value.trim();
|
||||
|
||||
// Inline Loading
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-sm"></span>';
|
||||
|
||||
msg.textContent = 'Validating...';
|
||||
msg.style.color = 'var(--text-muted)';
|
||||
|
||||
let isValid = true;
|
||||
let errStr = '';
|
||||
|
||||
if (val.length > 0 && validationType === 'channel') {
|
||||
isValid = await validateChannel(val);
|
||||
if (!isValid) errStr = "Invalid Channel ID (not visible)";
|
||||
} else if (val.length > 0 && validationType === 'channel_list') {
|
||||
const ids = val.split(',').map(s => s.trim());
|
||||
for (let id of ids) {
|
||||
if (id.length > 0 && !(await validateChannel(id))) {
|
||||
isValid = false;
|
||||
errStr = `Invalid Channel ID: ${id}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
msg.textContent = errStr;
|
||||
msg.style.color = 'var(--danger-color)';
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
return;
|
||||
}
|
||||
|
||||
await saveConfig(key, val, 'Service Config');
|
||||
msg.textContent = 'Saved!';
|
||||
msg.style.color = 'var(--accent-color)';
|
||||
setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText; // Restore text
|
||||
|
||||
refreshData(true); // Silent
|
||||
};
|
||||
|
||||
async function validateChannel(channelId) {
|
||||
try {
|
||||
const res = await fetch(`/api/guilds/${currentGuildId}/channels/${channelId}`);
|
||||
return res.ok;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// --- Config Tab (Custom) ---
|
||||
function renderConfigTab() {
|
||||
configTableBody.innerHTML = '';
|
||||
|
||||
// Exclude known service keys and admins from the generic table
|
||||
const serviceKeys = Object.values(SERVICE_CONFIG_MAP).flatMap(m => m.keys);
|
||||
const otherConfigs = currentConfigs.filter(c =>
|
||||
!serviceKeys.includes(c.key) && c.key !== 'ADMIN_IDS'
|
||||
);
|
||||
|
||||
if (otherConfigs.length === 0) {
|
||||
configTableBody.innerHTML = '<tr><td colspan="4" style="text-align:center">No custom configurations.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
otherConfigs.forEach(c => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${c.key}</td>
|
||||
<td>${c.value}</td>
|
||||
<td>${c.description || ''}</td>
|
||||
<td>
|
||||
<button class="btn danger action-btn" onclick="deleteConfig('${c.key}')">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
configTableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
window.deleteConfig = async (key) => {
|
||||
if (!confirm('Delete config?')) return;
|
||||
try {
|
||||
await fetch(`/api/guilds/${currentGuildId}/config/${key}`, { method: 'DELETE' });
|
||||
refreshData(true); // Silent
|
||||
} catch (e) { alert(e); }
|
||||
};
|
||||
|
||||
function formatName(str) {
|
||||
return str.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
/* Global Vars for Dark Mode */
|
||||
:root {
|
||||
--bg-color: #1a1a1a;
|
||||
--sidebar-bg: #121212;
|
||||
--sidebar-item-hover: #ffffff0d;
|
||||
--card-bg: #262626;
|
||||
--text-color: #e5e5e5;
|
||||
--text-muted: #a3a3a3;
|
||||
--border-color: #404040;
|
||||
--accent-color: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--danger-color: #ef4444;
|
||||
--header-bg: #1f1f1f;
|
||||
--bg-surface: #1f1f1f;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Segoe UI', Tahoma, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background-color: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: #00000022;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 24px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.brand-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-header span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
padding: 20px 20px 10px 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.server-list {
|
||||
list-style: none;
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.server-item {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #d4d4d4;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
background-color: var(--sidebar-item-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.server-item.active {
|
||||
background-color: #3b82f622;
|
||||
color: var(--accent-color);
|
||||
border-color: #3b82f644;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
height: 64px;
|
||||
background-color: var(--header-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.top-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-profile::before {
|
||||
content: "";
|
||||
/* Avatar placeholder */
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #333;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 30px;
|
||||
overflow-y: auto;
|
||||
/* Custom scrollbar */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #404040 transparent;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 12px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent-color);
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 1.5rem 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background-color: #171717;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
/* Services */
|
||||
.service-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
/* overflow: hidden; Removed to allow dropdowns to show */
|
||||
}
|
||||
|
||||
.service-header {
|
||||
background-color: #1f1f1f;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.service-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.service-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.service-body.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background-color: #404040;
|
||||
color: white;
|
||||
border: 1px solid #525252;
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background-color: #525252;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background-color: transparent;
|
||||
color: var(--danger-color);
|
||||
border: 1px solid var(--danger-color);
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-container {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #1f1f1f;
|
||||
color: #d4d4d4;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.server-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: #1f1f1f;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #404040;
|
||||
transition: .3s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked+.slider {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
input:checked+.slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
/* Below header */
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-left-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay p {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Inline Spinner */
|
||||
/* Spinner for buttons */
|
||||
.spinner-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-left-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Multi-Select & Tags */
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 5px;
|
||||
padding: 5px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tag .remove {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tag .remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dropdown-results {
|
||||
position: absolute;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
margin-top: 5px;
|
||||
display: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-results.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
Reference in New Issue
Block a user